chore: merge upstream (#25)
This commit is contained in:
commit
d0eb84dbed
2
.github/workflows/frontend-fe.yml
vendored
2
.github/workflows/frontend-fe.yml
vendored
@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/nightly_installer.yml
vendored
2
.github/workflows/nightly_installer.yml
vendored
@ -2,7 +2,7 @@
|
|||||||
# #
|
# #
|
||||||
# # Linux nightly installer action
|
# # Linux nightly installer action
|
||||||
# # This action will try to install and setup
|
# # This action will try to install and setup
|
||||||
# # chatwoot on an Ubuntu 20.04 machine using
|
# # chatwoot on an Ubuntu 22.04 machine using
|
||||||
# # the linux installer script.
|
# # the linux installer script.
|
||||||
# #
|
# #
|
||||||
# # This is set to run daily at midnight.
|
# # This is set to run daily at midnight.
|
||||||
|
|||||||
2
.github/workflows/run_foss_spec.yml
vendored
2
.github/workflows/run_foss_spec.yml
vendored
@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg15
|
image: pgvector/pgvector:pg15
|
||||||
|
|||||||
2
.github/workflows/size-limit.yml
vendored
2
.github/workflows/size-limit.yml
vendored
@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder
|
|||||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||||
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
|
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
|
||||||
|
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
|
||||||
|
|
||||||
contact
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def instagram_channel?
|
||||||
|
inbox.channel_type == 'Channel::Instagram'
|
||||||
|
end
|
||||||
|
|
||||||
|
# There might be existing contact_inboxes created through Channel::FacebookPage
|
||||||
|
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
|
||||||
|
# while still reusing contacts if found in Facebook channels so that we can create
|
||||||
|
# new conversations with the same contact.
|
||||||
|
def find_contact_by_instagram_source_id(instagram_id)
|
||||||
|
return if instagram_id.blank?
|
||||||
|
|
||||||
|
existing_contact_inbox = ContactInbox.joins(:inbox)
|
||||||
|
.where(source_id: instagram_id)
|
||||||
|
.where(
|
||||||
|
'inboxes.channel_type = ? AND inboxes.account_id = ?',
|
||||||
|
'Channel::FacebookPage',
|
||||||
|
account.id
|
||||||
|
).first
|
||||||
|
|
||||||
|
existing_contact_inbox&.contact
|
||||||
|
end
|
||||||
|
|
||||||
def find_contact_by_identifier(identifier)
|
def find_contact_by_identifier(identifier)
|
||||||
return if identifier.blank?
|
return if identifier.blank?
|
||||||
|
|
||||||
|
|||||||
179
app/builders/messages/instagram/base_message_builder.rb
Normal file
179
app/builders/messages/instagram/base_message_builder.rb
Normal 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 false 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
|
||||||
@ -1,200 +1,42 @@
|
|||||||
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
|
||||||
# Assumptions
|
|
||||||
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
|
||||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
|
||||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
|
||||||
|
|
||||||
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
|
||||||
attr_reader :messaging
|
|
||||||
|
|
||||||
def initialize(messaging, inbox, outgoing_echo: false)
|
def initialize(messaging, inbox, outgoing_echo: false)
|
||||||
super()
|
super(messaging, inbox, outgoing_echo: outgoing_echo)
|
||||||
@messaging = messaging
|
|
||||||
@inbox = inbox
|
|
||||||
@outgoing_echo = outgoing_echo
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform
|
|
||||||
return if @inbox.channel.reauthorization_required?
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
build_message
|
|
||||||
end
|
|
||||||
rescue Koala::Facebook::AuthenticationError => e
|
|
||||||
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
|
|
||||||
Rails.logger.error e
|
|
||||||
@inbox.channel.authorization_error!
|
|
||||||
raise
|
|
||||||
rescue StandardError => e
|
|
||||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
|
||||||
true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def attachments
|
def get_story_object_from_source_id(source_id)
|
||||||
@messaging[:message][:attachments] || {}
|
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
|
||||||
|
|
||||||
|
response = HTTParty.get(url)
|
||||||
|
|
||||||
|
return JSON.parse(response.body).with_indifferent_access if response.success?
|
||||||
|
|
||||||
|
# Create message first if it doesn't exist
|
||||||
|
@message ||= conversation.messages.create!(message_params)
|
||||||
|
handle_error_response(response)
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_type
|
def handle_error_response(response)
|
||||||
@outgoing_echo ? :outgoing : :incoming
|
parsed_response = JSON.parse(response.body)
|
||||||
end
|
error_code = parsed_response.dig('error', 'code')
|
||||||
|
|
||||||
def message_identifier
|
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||||
message[:mid]
|
# Access token has expired or become invalid.
|
||||||
end
|
channel.authorization_error! if error_code == 190
|
||||||
|
|
||||||
def message_source_id
|
# There was a problem scraping data from the provided link.
|
||||||
@outgoing_echo ? recipient_id : sender_id
|
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
|
||||||
end
|
if error_code == 1_609_005
|
||||||
|
@message.attachments.destroy_all
|
||||||
def message_is_unsupported?
|
@message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
def sender_id
|
|
||||||
@messaging[:sender][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
def recipient_id
|
|
||||||
@messaging[:recipient][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
def message
|
|
||||||
@messaging[:message]
|
|
||||||
end
|
|
||||||
|
|
||||||
def contact
|
|
||||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation
|
|
||||||
@conversation ||= set_conversation_based_on_inbox_config
|
|
||||||
end
|
|
||||||
|
|
||||||
def instagram_direct_message_conversation
|
|
||||||
Conversation.where(conversation_params)
|
|
||||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_conversation_based_on_inbox_config
|
|
||||||
if @inbox.lock_to_single_conversation
|
|
||||||
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
|
|
||||||
else
|
|
||||||
find_or_build_for_multiple_conversations
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_build_for_multiple_conversations
|
def base_uri
|
||||||
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
|
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
|
||||||
|
|
||||||
return build_conversation if last_conversation.nil?
|
|
||||||
|
|
||||||
last_conversation
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_content
|
|
||||||
@messaging[:message][:text]
|
|
||||||
end
|
|
||||||
|
|
||||||
def story_reply_attributes
|
|
||||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_reply_attributes
|
|
||||||
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_message
|
|
||||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
|
||||||
return if message_content.blank? && all_unsupported_files?
|
|
||||||
|
|
||||||
@message = conversation.messages.create!(message_params)
|
|
||||||
save_story_id
|
|
||||||
|
|
||||||
attachments.each do |attachment|
|
|
||||||
process_attachment(attachment)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_story_id
|
|
||||||
return if story_reply_attributes.blank?
|
|
||||||
|
|
||||||
@message.save_story_info(story_reply_attributes)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_conversation
|
|
||||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
|
||||||
|
|
||||||
Conversation.create!(conversation_params.merge(
|
|
||||||
contact_inbox_id: @contact_inbox.id,
|
|
||||||
additional_attributes: { type: 'instagram_direct_message' }
|
|
||||||
))
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_params
|
|
||||||
{
|
|
||||||
account_id: @inbox.account_id,
|
|
||||||
inbox_id: @inbox.id,
|
|
||||||
contact_id: contact.id
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_params
|
|
||||||
params = {
|
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
message_type: message_type,
|
|
||||||
source_id: message_identifier,
|
|
||||||
content: message_content,
|
|
||||||
sender: @outgoing_echo ? nil : contact,
|
|
||||||
content_attributes: {
|
|
||||||
in_reply_to_external_id: message_reply_attributes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
|
||||||
params
|
|
||||||
end
|
|
||||||
|
|
||||||
def already_sent_from_chatwoot?
|
|
||||||
cw_message = conversation.messages.where(
|
|
||||||
source_id: @messaging[:message][:mid]
|
|
||||||
).first
|
|
||||||
|
|
||||||
cw_message.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_unsupported_files?
|
|
||||||
return false if attachments.empty?
|
|
||||||
|
|
||||||
attachments_type = attachments.pluck(:type).uniq.first
|
|
||||||
unsupported_file_type?(attachments_type)
|
|
||||||
end
|
|
||||||
|
|
||||||
### Sample response
|
|
||||||
# {
|
|
||||||
# "object": "instagram",
|
|
||||||
# "entry": [
|
|
||||||
# {
|
|
||||||
# "id": "<IGID>",// ig id of the business
|
|
||||||
# "time": 1569262486134,
|
|
||||||
# "messaging": [
|
|
||||||
# {
|
|
||||||
# "sender": {
|
|
||||||
# "id": "<IGSID>"
|
|
||||||
# },
|
|
||||||
# "recipient": {
|
|
||||||
# "id": "<IGID>"
|
|
||||||
# },
|
|
||||||
# "timestamp": 1569262485349,
|
|
||||||
# "message": {
|
|
||||||
# "mid": "<MESSAGE_ID>",
|
|
||||||
# "text": "<MESSAGE_CONTENT>"
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# ]
|
|
||||||
# }
|
|
||||||
# ],
|
|
||||||
# }
|
|
||||||
end
|
end
|
||||||
|
|||||||
33
app/builders/messages/instagram/messenger/message_builder.rb
Normal file
33
app/builders/messages/instagram/messenger/message_builder.rb
Normal 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
|
||||||
@ -68,20 +68,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_story_object_from_source_id(source_id)
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allowed_agent_params
|
def allowed_agent_params
|
||||||
[:name, :email, :name, :role, :availability, :auto_offline]
|
[:name, :email, :role, :availability, :auto_offline]
|
||||||
end
|
end
|
||||||
|
|
||||||
def agent_params
|
def agent_params
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
module InstagramConcern
|
module InstagramConcern
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
include HTTParty
|
|
||||||
|
|
||||||
def instagram_client
|
def instagram_client
|
||||||
::OAuth2::Client.new(
|
::OAuth2::Client.new(
|
||||||
|
|||||||
@ -29,6 +29,19 @@
|
|||||||
--iris-11: 87 83 198;
|
--iris-11: 87 83 198;
|
||||||
--iris-12: 39 41 98;
|
--iris-12: 39 41 98;
|
||||||
|
|
||||||
|
--blue-1: 251 253 255;
|
||||||
|
--blue-2: 245 249 255;
|
||||||
|
--blue-3: 233 243 255;
|
||||||
|
--blue-4: 218 236 255;
|
||||||
|
--blue-5: 201 226 255;
|
||||||
|
--blue-6: 181 213 255;
|
||||||
|
--blue-7: 155 195 252;
|
||||||
|
--blue-8: 117 171 247;
|
||||||
|
--blue-9: 39 129 246;
|
||||||
|
--blue-10: 16 115 233;
|
||||||
|
--blue-11: 8 109 224;
|
||||||
|
--blue-12: 11 50 101;
|
||||||
|
|
||||||
--ruby-1: 255 252 253;
|
--ruby-1: 255 252 253;
|
||||||
--ruby-2: 255 247 248;
|
--ruby-2: 255 247 248;
|
||||||
--ruby-3: 254 234 237;
|
--ruby-3: 254 234 237;
|
||||||
@ -131,6 +144,19 @@
|
|||||||
--iris-11: 158 177 255;
|
--iris-11: 158 177 255;
|
||||||
--iris-12: 224 223 254;
|
--iris-12: 224 223 254;
|
||||||
|
|
||||||
|
--blue-1: 10 17 28;
|
||||||
|
--blue-2: 15 24 38;
|
||||||
|
--blue-3: 15 39 72;
|
||||||
|
--blue-4: 10 49 99;
|
||||||
|
--blue-5: 18 61 117;
|
||||||
|
--blue-6: 29 84 134;
|
||||||
|
--blue-7: 40 89 156;
|
||||||
|
--blue-8: 48 106 186;
|
||||||
|
--blue-9: 39 129 246;
|
||||||
|
--blue-10: 21 116 231;
|
||||||
|
--blue-11: 126 182 255;
|
||||||
|
--blue-12: 205 227 255;
|
||||||
|
|
||||||
--ruby-1: 25 17 19;
|
--ruby-1: 25 17 19;
|
||||||
--ruby-2: 30 21 23;
|
--ruby-2: 30 21 23;
|
||||||
--ruby-3: 58 20 30;
|
--ruby-3: 58 20 30;
|
||||||
|
|||||||
@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
|||||||
<Button
|
<Button
|
||||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||||
variant="link"
|
variant="link"
|
||||||
|
type="reset"
|
||||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||||
@click="closeDialog"
|
@click="closeDialog"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -31,10 +31,6 @@ const sortMenus = [
|
|||||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||||
value: 'email',
|
value: 'email',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
|
||||||
value: 'phone_number',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||||
value: 'company_name',
|
value: 'company_name',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
||||||
'Channel::WebWidget': 'i-ri-global-fill',
|
'Channel::WebWidget': 'i-ri-global-fill',
|
||||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||||
|
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const {
|
|||||||
isAWebWidgetInbox,
|
isAWebWidgetInbox,
|
||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
|
isAInstagramChannel,
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
const { status, isPrivate, createdAt, sourceId, messageType } =
|
const { status, isPrivate, createdAt, sourceId, messageType } =
|
||||||
@ -47,7 +48,8 @@ const isSent = computed(() => {
|
|||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value
|
isATelegramChannel.value ||
|
||||||
|
isAInstagramChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@ -86,7 +88,8 @@ const isRead = computed(() => {
|
|||||||
if (
|
if (
|
||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value
|
isAFacebookInbox.value ||
|
||||||
|
isAInstagramChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
@ -102,7 +105,6 @@ const statusToShow = computed(() => {
|
|||||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||||
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
|
|
||||||
|
|
||||||
return MESSAGE_STATUS.PROGRESS;
|
return MESSAGE_STATUS.PROGRESS;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -218,14 +218,14 @@ const emitDateRange = () => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="showDatePicker"
|
v-if="showDatePicker"
|
||||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
|
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||||
>
|
>
|
||||||
<CalendarDateRange
|
<CalendarDateRange
|
||||||
:selected-range="selectedRange"
|
:selected-range="selectedRange"
|
||||||
@set-range="setDateRange"
|
@set-range="setDateRange"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
|
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
|
||||||
>
|
>
|
||||||
<div class="flex justify-around h-fit">
|
<div class="flex justify-around h-fit">
|
||||||
<!-- Calendars for Start and End Dates -->
|
<!-- Calendars for Start and End Dates -->
|
||||||
@ -251,12 +251,12 @@ const emitDateRange = () => {
|
|||||||
@validate="updateManualInput($event, calendar)"
|
@validate="updateManualInput($event, calendar)"
|
||||||
@error="handleManualInputError($event)"
|
@error="handleManualInputError($event)"
|
||||||
/>
|
/>
|
||||||
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
|
<div class="py-5 border-b border-n-strong">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
|
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
|
||||||
:class="
|
:class="
|
||||||
calendar === START_CALENDAR &&
|
calendar === START_CALENDAR &&
|
||||||
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
|
'ltr:border-r rtl:border-l border-n-strong'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<CalendarYear
|
<CalendarYear
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
|
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
|
||||||
|
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
calendarType: {
|
calendarType: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -38,42 +40,38 @@ const onClickSetView = (type, mode) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-start justify-between w-full h-9">
|
<div class="flex items-start justify-between w-full h-9">
|
||||||
<button
|
<NextButton
|
||||||
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
|
slate
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
icon="i-lucide-chevron-left"
|
||||||
|
class="rtl:rotate-180"
|
||||||
@click="onClickPrev(calendarType)"
|
@click="onClickPrev(calendarType)"
|
||||||
>
|
/>
|
||||||
<fluent-icon
|
|
||||||
icon="chevron-left"
|
|
||||||
size="14"
|
|
||||||
class="text-slate-900 dark:text-slate-50"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
v-if="firstButtonLabel"
|
v-if="firstButtonLabel"
|
||||||
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
|
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||||
@click="onClickSetView(calendarType, viewMode)"
|
@click="onClickSetView(calendarType, viewMode)"
|
||||||
>
|
>
|
||||||
{{ firstButtonLabel }}
|
{{ firstButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="buttonLabel"
|
v-if="buttonLabel"
|
||||||
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
|
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||||
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
|
:class="{ 'hover:text-n-brand': viewMode }"
|
||||||
@click="onClickSetView(calendarType, YEAR)"
|
@click="onClickSetView(calendarType, YEAR)"
|
||||||
>
|
>
|
||||||
{{ buttonLabel }}
|
{{ buttonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<NextButton
|
||||||
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
|
slate
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
class="rtl:rotate-180"
|
||||||
@click="onClickNext(calendarType)"
|
@click="onClickNext(calendarType)"
|
||||||
>
|
/>
|
||||||
<fluent-icon
|
|
||||||
icon="chevron-right"
|
|
||||||
size="14"
|
|
||||||
class="text-slate-900 dark:text-slate-50"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -65,7 +65,7 @@ const validateDate = () => {
|
|||||||
<input
|
<input
|
||||||
v-model="localDateValue"
|
v-model="localDateValue"
|
||||||
type="text"
|
type="text"
|
||||||
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
|
class="!text-sm !mb-0 disabled:!outline-n-strong"
|
||||||
:placeholder="dateFormat"
|
:placeholder="dateFormat"
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
@keypress.enter="validateDate"
|
@keypress.enter="validateDate"
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const setDateRange = range => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-[200px] flex flex-col items-start">
|
<div class="w-[200px] flex flex-col items-start">
|
||||||
<h4
|
<h4
|
||||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
|
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||||
</h4>
|
</h4>
|
||||||
@ -26,11 +26,11 @@ const setDateRange = range => {
|
|||||||
<button
|
<button
|
||||||
v-for="range in dateRanges"
|
v-for="range in dateRanges"
|
||||||
:key="range.label"
|
:key="range.label"
|
||||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
|
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||||
:class="
|
:class="
|
||||||
range.value === selectedRange
|
range.value === selectedRange
|
||||||
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
|
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||||
: 'text-slate-600 dark:text-slate-200'
|
: 'text-n-slate-12'
|
||||||
"
|
"
|
||||||
@click="setDateRange(range)"
|
@click="setDateRange(range)"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['clear', 'change']);
|
const emit = defineEmits(['clear', 'change']);
|
||||||
|
|
||||||
const onClickClear = () => {
|
const onClickClear = () => {
|
||||||
@ -11,18 +13,19 @@ const onClickApply = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
|
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
|
||||||
<button
|
<NextButton
|
||||||
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
|
slate
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
|
||||||
@click="onClickClear"
|
@click="onClickClear"
|
||||||
>
|
/>
|
||||||
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
|
<NextButton
|
||||||
</button>
|
sm
|
||||||
<button
|
ghost
|
||||||
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
|
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||||
@click="onClickApply"
|
@click="onClickApply"
|
||||||
>
|
/>
|
||||||
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -71,10 +71,12 @@ const selectMonth = index => {
|
|||||||
<button
|
<button
|
||||||
v-for="(month, index) in months"
|
v-for="(month, index) in months"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
|
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
|
'bg-n-brand text-white hover:bg-n-blue-10':
|
||||||
index === activeMonthIndex,
|
index === activeMonthIndex,
|
||||||
|
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||||
|
index !== activeMonthIndex,
|
||||||
}"
|
}"
|
||||||
@click="selectMonth(index)"
|
@click="selectMonth(index)"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -107,17 +107,16 @@ const isNextDayInRange = day => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dayClasses = day => ({
|
const dayClasses = day => ({
|
||||||
'text-slate-500 dark:text-slate-400 pointer-events-none':
|
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
|
||||||
!isInCurrentMonth(day),
|
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
|
||||||
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
|
|
||||||
isInCurrentMonth(day),
|
isInCurrentMonth(day),
|
||||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
|
'bg-n-brand text-white':
|
||||||
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
||||||
'bg-woot-50 dark:bg-woot-800':
|
'bg-n-blue-4 dark:bg-n-blue-5':
|
||||||
(isInRange(day) || isHoveringInRange(day)) &&
|
(isInRange(day) || isHoveringInRange(day)) &&
|
||||||
!isSelectedStartOrEndDate(day) &&
|
!isSelectedStartOrEndDate(day) &&
|
||||||
isInCurrentMonth(day),
|
isInCurrentMonth(day),
|
||||||
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
|
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
|
||||||
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
|
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -164,7 +163,7 @@ const dayClasses = day => ({
|
|||||||
!isLastDayOfMonth(day) &&
|
!isLastDayOfMonth(day) &&
|
||||||
isInCurrentMonth(day)
|
isInCurrentMonth(day)
|
||||||
"
|
"
|
||||||
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
|
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -72,10 +72,10 @@ const selectYear = year => {
|
|||||||
<button
|
<button
|
||||||
v-for="year in years"
|
v-for="year in years"
|
||||||
:key="year"
|
:key="year"
|
||||||
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
|
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
|
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||||
year === activeYear,
|
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||||
}"
|
}"
|
||||||
@click="selectYear(year)"
|
@click="selectYear(year)"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const openDatePicker = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
|
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
|
||||||
@click="openDatePicker"
|
@click="openDatePicker"
|
||||||
>
|
>
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
|
|||||||
@ -32,6 +32,10 @@ export default {
|
|||||||
return this.enabledFeatures.channel_email;
|
return this.enabledFeatures.channel_email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'instagram') {
|
||||||
|
return this.enabledFeatures.channel_instagram;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'website',
|
'website',
|
||||||
'twilio',
|
'twilio',
|
||||||
@ -40,6 +44,7 @@ export default {
|
|||||||
'sms',
|
'sms',
|
||||||
'telegram',
|
'telegram',
|
||||||
'line',
|
'line',
|
||||||
|
'instagram',
|
||||||
].includes(key);
|
].includes(key);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -220,6 +220,7 @@ const plugins = computed(() => {
|
|||||||
trigger: '@',
|
trigger: '@',
|
||||||
showMenu: showUserMentions,
|
showMenu: showUserMentions,
|
||||||
searchTerm: mentionSearchKey,
|
searchTerm: mentionSearchKey,
|
||||||
|
isAllowed: () => props.isPrivate,
|
||||||
}),
|
}),
|
||||||
createSuggestionPlugin({
|
createSuggestionPlugin({
|
||||||
trigger: '/',
|
trigger: '/',
|
||||||
@ -774,10 +775,24 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-prompt {
|
.ProseMirror-prompt {
|
||||||
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
|
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
@apply dark:text-slate-25 text-slate-800;
|
@apply text-n-slate-12 mb-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-prompt-buttons {
|
||||||
|
button {
|
||||||
|
@apply h-8 px-3;
|
||||||
|
|
||||||
|
&[type='submit'] {
|
||||||
|
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[type='button'] {
|
||||||
|
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -328,11 +328,24 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-prompt {
|
.ProseMirror-prompt {
|
||||||
z-index: var(--z-index-highest);
|
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||||
background: var(--white);
|
|
||||||
box-shadow: var(--shadow-large);
|
h5 {
|
||||||
border-radius: var(--border-radius-normal);
|
@apply text-n-slate-12 mb-1.5;
|
||||||
border: 1px solid var(--color-border);
|
}
|
||||||
min-width: 25rem;
|
|
||||||
|
.ProseMirror-prompt-buttons {
|
||||||
|
button {
|
||||||
|
@apply h-8 px-3;
|
||||||
|
|
||||||
|
&[type='submit'] {
|
||||||
|
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[type='button'] {
|
||||||
|
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ALLOWED_FILE_TYPES,
|
ALLOWED_FILE_TYPES,
|
||||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||||
|
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
|
||||||
} from 'shared/constants/messages';
|
} from 'shared/constants/messages';
|
||||||
import VideoCallButton from '../VideoCallButton.vue';
|
import VideoCallButton from '../VideoCallButton.vue';
|
||||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||||
@ -113,6 +114,10 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
conversationType: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
'replaceText',
|
||||||
@ -187,6 +192,9 @@ export default {
|
|||||||
showAudioPlayStopButton() {
|
showAudioPlayStopButton() {
|
||||||
return this.showAudioRecorder && this.isRecordingAudio;
|
return this.showAudioRecorder && this.isRecordingAudio;
|
||||||
},
|
},
|
||||||
|
isInstagramDM() {
|
||||||
|
return this.conversationType === 'instagram_direct_message';
|
||||||
|
},
|
||||||
allowedFileTypes() {
|
allowedFileTypes() {
|
||||||
if (this.isATwilioWhatsAppChannel) {
|
if (this.isATwilioWhatsAppChannel) {
|
||||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||||
@ -194,6 +202,10 @@ export default {
|
|||||||
if (this.isALineChannel) {
|
if (this.isALineChannel) {
|
||||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||||
}
|
}
|
||||||
|
if (this.isAInstagramChannel || this.isInstagramDM) {
|
||||||
|
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||||
|
}
|
||||||
|
|
||||||
return ALLOWED_FILE_TYPES;
|
return ALLOWED_FILE_TYPES;
|
||||||
},
|
},
|
||||||
enableDragAndDrop() {
|
enableDragAndDrop() {
|
||||||
|
|||||||
@ -235,16 +235,23 @@ export default {
|
|||||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||||
},
|
},
|
||||||
replyWindowLink() {
|
replyWindowLink() {
|
||||||
if (this.isAWhatsAppChannel) {
|
if (this.isAFacebookInbox || this.isAInstagramChannel) {
|
||||||
return REPLY_POLICY.FACEBOOK;
|
return REPLY_POLICY.FACEBOOK;
|
||||||
}
|
}
|
||||||
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
|
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||||
|
}
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
replyWindowLinkText() {
|
replyWindowLinkText() {
|
||||||
if (this.isAWhatsAppChannel) {
|
if (
|
||||||
|
this.isAWhatsAppChannel ||
|
||||||
|
this.isAFacebookInbox ||
|
||||||
|
this.isAInstagramChannel
|
||||||
|
) {
|
||||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
@ -533,7 +540,7 @@ export default {
|
|||||||
<Banner
|
<Banner
|
||||||
v-if="!currentChat.can_reply"
|
v-if="!currentChat.can_reply"
|
||||||
color-scheme="alert"
|
color-scheme="alert"
|
||||||
class="mt-2 mx-2 rounded-lg overflow-hidden"
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
:banner-message="replyWindowBannerMessage"
|
:banner-message="replyWindowBannerMessage"
|
||||||
:href-link="replyWindowLink"
|
:href-link="replyWindowLink"
|
||||||
:href-link-text="replyWindowLinkText"
|
:href-link-text="replyWindowLinkText"
|
||||||
|
|||||||
@ -261,7 +261,8 @@ export default {
|
|||||||
this.isAnEmailChannel ||
|
this.isAnEmailChannel ||
|
||||||
this.isASmsInbox ||
|
this.isASmsInbox ||
|
||||||
this.isATelegramChannel ||
|
this.isATelegramChannel ||
|
||||||
this.isALineChannel
|
this.isALineChannel ||
|
||||||
|
this.isAInstagramChannel
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replyButtonLabel() {
|
replyButtonLabel() {
|
||||||
@ -1076,7 +1077,7 @@ export default {
|
|||||||
v-if="showSelfAssignBanner"
|
v-if="showSelfAssignBanner"
|
||||||
action-button-variant="ghost"
|
action-button-variant="ghost"
|
||||||
color-scheme="secondary"
|
color-scheme="secondary"
|
||||||
class="banner--self-assign mx-2 mb-2 rounded-lg"
|
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||||
has-action-button
|
has-action-button
|
||||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||||
@ -1195,6 +1196,7 @@ export default {
|
|||||||
:mode="replyType"
|
:mode="replyType"
|
||||||
:on-file-upload="onFileUpload"
|
:on-file-upload="onFileUpload"
|
||||||
:on-send="onSendReply"
|
:on-send="onSendReply"
|
||||||
|
:conversation-type="conversationType"
|
||||||
:recording-audio-duration-text="recordingAudioDurationText"
|
:recording-audio-duration-text="recordingAudioDurationText"
|
||||||
:recording-audio-state="recordingAudioState"
|
:recording-audio-state="recordingAudioState"
|
||||||
:send-button-text="replyButtonLabel"
|
:send-button-text="replyButtonLabel"
|
||||||
|
|||||||
@ -69,10 +69,6 @@ const onAgentSelect = index => {
|
|||||||
v-if="items.length"
|
v-if="items.length"
|
||||||
ref="tagAgentsRef"
|
ref="tagAgentsRef"
|
||||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||||
:class="{
|
|
||||||
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
|
|
||||||
items.length <= 4,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(agent, index) in items"
|
v-for="(agent, index) in items"
|
||||||
|
|||||||
@ -128,6 +128,10 @@ export const useInbox = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAInstagramChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inbox,
|
inbox,
|
||||||
isAFacebookInbox,
|
isAFacebookInbox,
|
||||||
@ -145,5 +149,6 @@ export const useInbox = () => {
|
|||||||
is360DialogWhatsAppChannel,
|
is360DialogWhatsAppChannel,
|
||||||
isAWhatsAppBaileysChannel,
|
isAWhatsAppBaileysChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
|
isAInstagramChannel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export const FEATURE_FLAGS = {
|
|||||||
CUSTOM_ROLES: 'custom_roles',
|
CUSTOM_ROLES: 'custom_roles',
|
||||||
CHATWOOT_V4: 'chatwoot_v4',
|
CHATWOOT_V4: 'chatwoot_v4',
|
||||||
REPORT_V4: 'report_v4',
|
REPORT_V4: 'report_v4',
|
||||||
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,15 @@ export const getUserPermissions = (user, accountId) => {
|
|||||||
return currentAccount.permissions || [];
|
return currentAccount.permissions || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getUserRole = (user, accountId) => {
|
||||||
|
const currentAccount = getCurrentAccount(user, accountId) || {};
|
||||||
|
if (currentAccount.custom_role_id) {
|
||||||
|
return 'custom_role';
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentAccount.role || 'agent';
|
||||||
|
};
|
||||||
|
|
||||||
const isPermissionsPresentInRoute = route =>
|
const isPermissionsPresentInRoute = route =>
|
||||||
route.meta && route.meta.permissions;
|
route.meta && route.meta.permissions;
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"INSTAGRAM": {
|
"INSTAGRAM": {
|
||||||
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
|
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
|
||||||
|
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
|
||||||
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
|
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
|
||||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
|
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
|
||||||
|
|||||||
@ -35,6 +35,8 @@ export default {
|
|||||||
},
|
},
|
||||||
{ key: 'telegram', name: 'Telegram' },
|
{ key: 'telegram', name: 'Telegram' },
|
||||||
{ key: 'line', name: 'Line' },
|
{ key: 'line', name: 'Line' },
|
||||||
|
// TODO: Add Instagram to the channel list after the feature is ready to use.
|
||||||
|
// { key: 'instagram', name: 'Instagram' },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
@ -62,7 +64,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
class="max-w-4xl"
|
class="max-w-4xl"
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
|
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
<div class="flex flex-col items-center justify-start h-full text-center">
|
||||||
<div v-if="hasError" class="max-w-lg mx-auto text-center">
|
<div v-if="hasError" class="max-w-lg mx-auto text-center">
|
||||||
<h5>{{ errorStateMessage }}</h5>
|
<h5>{{ errorStateMessage }}</h5>
|
||||||
<p
|
<p
|
||||||
@ -77,23 +77,20 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex flex-col items-center justify-center h-full text-center"
|
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
|
||||||
>
|
>
|
||||||
|
<h6 class="text-2xl font-medium">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
|
||||||
|
</h6>
|
||||||
|
<p class="py-6 text-sm text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center px-8 py-3.5 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
|
class="flex items-center justify-center px-8 py-3.5 gap-2 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
|
||||||
:disabled="isRequestingAuthorization"
|
:disabled="isRequestingAuthorization"
|
||||||
@click="requestAuthorization()"
|
@click="requestAuthorization()"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="i-ri-instagram-line size-5" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-5 h-5 mr-2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-base font-medium">
|
<span class="text-base font-medium">
|
||||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
|
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
|
||||||
</span>
|
</span>
|
||||||
@ -120,9 +117,6 @@ export default {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<p class="py-6">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -189,10 +189,4 @@ export default {
|
|||||||
.formkit-actions {
|
.formkit-actions {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.pre-chat-header-message .link {
|
|
||||||
@apply text-woot-500 underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
import { applyPageFilters, sortComparator } from './helpers';
|
import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
|
||||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||||
import { matchesFilters } from './helpers/filterHelpers';
|
import { matchesFilters } from './helpers/filterHelpers';
|
||||||
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
getUserRole,
|
||||||
|
} from '../../../helper/permissionsHelper';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
export const getSelectedChatConversation = ({
|
export const getSelectedChatConversation = ({
|
||||||
@ -77,10 +81,24 @@ const getters = {
|
|||||||
return isUnAssigned && shouldFilter;
|
return isUnAssigned && shouldFilter;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getAllStatusChats: _state => activeFilters => {
|
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
|
||||||
|
const currentUser = rootGetters.getCurrentUser;
|
||||||
|
const currentUserId = rootGetters.getCurrentUser.id;
|
||||||
|
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||||
|
|
||||||
|
const permissions = getUserPermissions(currentUser, currentAccountId);
|
||||||
|
const userRole = getUserRole(currentUser, currentAccountId);
|
||||||
|
|
||||||
return _state.allConversations.filter(conversation => {
|
return _state.allConversations.filter(conversation => {
|
||||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||||
return shouldFilter;
|
const allowedForRole = applyRoleFilter(
|
||||||
|
conversation,
|
||||||
|
userRole,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldFilter && allowedForRole;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
|
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
|
||||||
|
|||||||
@ -62,6 +62,51 @@ export const applyPageFilters = (conversation, filters) => {
|
|||||||
return shouldFilter;
|
return shouldFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters conversations based on user role and permissions
|
||||||
|
*
|
||||||
|
* @param {Object} conversation - The conversation object to check permissions for
|
||||||
|
* @param {string} role - The user's role (administrator, agent, etc.)
|
||||||
|
* @param {Array<string>} permissions - List of permission strings the user has
|
||||||
|
* @param {number|string} currentUserId - The ID of the current user
|
||||||
|
* @returns {boolean} - Whether the user has permissions to access this conversation
|
||||||
|
*/
|
||||||
|
export const applyRoleFilter = (
|
||||||
|
conversation,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
) => {
|
||||||
|
// the role === "agent" check is typically not correct on it's own
|
||||||
|
// the backend handles this by checking the custom_role_id at the user model
|
||||||
|
// here however, the `getUserRole` returns "custom_role" if the id is present,
|
||||||
|
// so we can check the role === "agent" directly
|
||||||
|
if (['administrator', 'agent'].includes(role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for full conversation management permission
|
||||||
|
if (permissions.includes('conversation_manage')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationAssignee = conversation.meta.assignee;
|
||||||
|
const isUnassigned = !conversationAssignee;
|
||||||
|
const isAssignedToUser = conversationAssignee?.id === currentUserId;
|
||||||
|
|
||||||
|
// Check unassigned management permission
|
||||||
|
if (permissions.includes('conversation_unassigned_manage')) {
|
||||||
|
return isUnassigned || isAssignedToUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check participating conversation management permission
|
||||||
|
if (permissions.includes('conversation_participating_manage')) {
|
||||||
|
return isAssignedToUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const SORT_OPTIONS = {
|
const SORT_OPTIONS = {
|
||||||
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
|
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
|
||||||
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
|
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
|
||||||
|
|||||||
@ -0,0 +1,276 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { applyRoleFilter } from '../helpers';
|
||||||
|
|
||||||
|
describe('Conversation Helpers', () => {
|
||||||
|
describe('#applyRoleFilter', () => {
|
||||||
|
// Test data for conversations
|
||||||
|
const conversationWithAssignee = {
|
||||||
|
meta: {
|
||||||
|
assignee: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationWithDifferentAssignee = {
|
||||||
|
meta: {
|
||||||
|
assignee: {
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationWithoutAssignee = {
|
||||||
|
meta: {
|
||||||
|
assignee: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test for administrator role
|
||||||
|
it('always returns true for administrator role regardless of permissions', () => {
|
||||||
|
const role = 'administrator';
|
||||||
|
const permissions = [];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for agent role
|
||||||
|
it('always returns true for agent role regardless of permissions', () => {
|
||||||
|
const role = 'agent';
|
||||||
|
const permissions = [];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for custom role with 'conversation_manage' permission
|
||||||
|
it('returns true for any user with conversation_manage permission', () => {
|
||||||
|
const role = 'custom_role';
|
||||||
|
const permissions = ['conversation_manage'];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for custom role with 'conversation_unassigned_manage' permission
|
||||||
|
describe('with conversation_unassigned_manage permission', () => {
|
||||||
|
const role = 'custom_role';
|
||||||
|
const permissions = ['conversation_unassigned_manage'];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
it('returns true for conversations assigned to the user', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for unassigned conversations', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for conversations assigned to other users', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for custom role with 'conversation_participating_manage' permission
|
||||||
|
describe('with conversation_participating_manage permission', () => {
|
||||||
|
const role = 'custom_role';
|
||||||
|
const permissions = ['conversation_participating_manage'];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
it('returns true for conversations assigned to the user', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for unassigned conversations', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for conversations assigned to other users', () => {
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for user with no relevant permissions
|
||||||
|
it('returns false for custom role without any relevant permissions', () => {
|
||||||
|
const role = 'custom_role';
|
||||||
|
const permissions = ['some_other_permission'];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithDifferentAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithoutAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test edge cases for meta.assignee
|
||||||
|
describe('handles edge cases with meta.assignee', () => {
|
||||||
|
const role = 'custom_role';
|
||||||
|
const permissions = ['conversation_unassigned_manage'];
|
||||||
|
const currentUserId = 1;
|
||||||
|
|
||||||
|
it('treats undefined assignee as unassigned', () => {
|
||||||
|
const conversationWithUndefinedAssignee = {
|
||||||
|
meta: {
|
||||||
|
assignee: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithUndefinedAssignee,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty meta object', () => {
|
||||||
|
const conversationWithEmptyMeta = {
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyRoleFilter(
|
||||||
|
conversationWithEmptyMeta,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,4 +3,6 @@ export const REPLY_POLICY = {
|
|||||||
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
|
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
|
||||||
TWILIO_WHATSAPP:
|
TWILIO_WHATSAPP:
|
||||||
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
|
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
|
||||||
|
WHATSAPP_CLOUD:
|
||||||
|
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -57,6 +57,10 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
|
|||||||
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
|
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
|
||||||
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
|
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
|
||||||
|
|
||||||
|
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
|
||||||
|
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
|
||||||
|
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
|
||||||
|
|
||||||
export const CSAT_RATINGS = [
|
export const CSAT_RATINGS = [
|
||||||
{
|
{
|
||||||
key: 'disappointed',
|
key: 'disappointed',
|
||||||
|
|||||||
@ -264,7 +264,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
v-if="shouldShowHeaderMessage"
|
v-if="shouldShowHeaderMessage"
|
||||||
v-dompurify-html="formatMessage(headerMessage, false)"
|
v-dompurify-html="formatMessage(headerMessage, false)"
|
||||||
class="mb-4 text-base leading-5 pre-chat-header-message text-n-slate-12"
|
class="mb-4 text-base leading-5 text-n-slate-12 [&>p>.link]:text-n-blue-text [&>p>.link]:hover:underline"
|
||||||
/>
|
/>
|
||||||
<!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
|
<!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
|
||||||
If we just pass the options as is even with null or undefined or false,
|
If we just pass the options as is even with null or undefined or false,
|
||||||
@ -354,12 +354,4 @@ export default {
|
|||||||
.formkit-messages {
|
.formkit-messages {
|
||||||
@apply list-none m-0 p-0;
|
@apply list-none m-0 p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.pre-chat-header-message {
|
|
||||||
.link {
|
|
||||||
@apply text-woot-500 underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -12,7 +12,8 @@ class SendReplyJob < ApplicationJob
|
|||||||
'Channel::Line' => ::Line::SendOnLineService,
|
'Channel::Line' => ::Line::SendOnLineService,
|
||||||
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
|
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
|
||||||
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
|
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
|
||||||
'Channel::Sms' => ::Sms::SendOnSmsService
|
'Channel::Sms' => ::Sms::SendOnSmsService,
|
||||||
|
'Channel::Instagram' => ::Instagram::SendOnInstagramService
|
||||||
}
|
}
|
||||||
|
|
||||||
case channel_name
|
case channel_name
|
||||||
@ -27,7 +28,7 @@ class SendReplyJob < ApplicationJob
|
|||||||
|
|
||||||
def send_on_facebook_page(message)
|
def send_on_facebook_page(message)
|
||||||
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
|
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
|
||||||
::Instagram::SendOnInstagramService.new(message: message).perform
|
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
|
||||||
else
|
else
|
||||||
::Facebook::SendOnFacebookService.new(message: message).perform
|
::Facebook::SendOnFacebookService.new(message: message).perform
|
||||||
end
|
end
|
||||||
|
|||||||
@ -2,10 +2,6 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
|||||||
queue_as :default
|
queue_as :default
|
||||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||||
|
|
||||||
include HTTParty
|
|
||||||
|
|
||||||
base_uri 'https://graph.facebook.com/v11.0/me'
|
|
||||||
|
|
||||||
# @return [Array] We will support further events like reaction or seen in future
|
# @return [Array] We will support further events like reaction or seen in future
|
||||||
SUPPORTED_EVENTS = [:message, :read].freeze
|
SUPPORTED_EVENTS = [:message, :read].freeze
|
||||||
|
|
||||||
@ -18,18 +14,46 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
|
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
|
||||||
def process_entries(entries)
|
def process_entries(entries)
|
||||||
entries.each do |entry|
|
entries.each do |entry|
|
||||||
entry = entry.with_indifferent_access
|
process_single_entry(entry.with_indifferent_access)
|
||||||
messages(entry).each do |messaging|
|
|
||||||
send(@event_name, messaging) if event_name(messaging)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def process_single_entry(entry)
|
||||||
|
process_messages(entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_messages(entry)
|
||||||
|
messages(entry).each do |messaging|
|
||||||
|
Rails.logger.info("Instagram Events Job Messaging: #{messaging}")
|
||||||
|
|
||||||
|
instagram_id = instagram_id(messaging)
|
||||||
|
channel = find_channel(instagram_id)
|
||||||
|
|
||||||
|
next if channel.blank?
|
||||||
|
|
||||||
|
if (event_name = event_name(messaging))
|
||||||
|
send(event_name, messaging, channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_message_via_echo?(messaging)
|
||||||
|
messaging[:message].present? && messaging[:message][:is_echo].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def instagram_id(messaging)
|
||||||
|
if agent_message_via_echo?(messaging)
|
||||||
|
messaging[:sender][:id]
|
||||||
|
else
|
||||||
|
messaging[:recipient][:id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def ig_account_id
|
def ig_account_id
|
||||||
@entries&.first&.dig(:id)
|
@entries&.first&.dig(:id)
|
||||||
end
|
end
|
||||||
@ -38,19 +62,116 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
|||||||
@entries&.dig(0, :messaging, 0, :sender, :id)
|
@entries&.dig(0, :messaging, 0, :sender, :id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def find_channel(instagram_id)
|
||||||
|
# There will be chances for the instagram account to be connected to a facebook page,
|
||||||
|
# so we need to check for both instagram and facebook page channels
|
||||||
|
# priority is for instagram channel which created via instagram login
|
||||||
|
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
|
||||||
|
# If not found, fallback to the facebook page channel
|
||||||
|
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
|
||||||
|
|
||||||
|
channel
|
||||||
|
end
|
||||||
|
|
||||||
def event_name(messaging)
|
def event_name(messaging)
|
||||||
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
|
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def message(messaging)
|
def message(messaging, channel)
|
||||||
::Instagram::MessageText.new(messaging).perform
|
if channel.is_a?(Channel::Instagram)
|
||||||
|
::Instagram::MessageText.new(messaging, channel).perform
|
||||||
|
else
|
||||||
|
::Instagram::Messenger::MessageText.new(messaging, channel).perform
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def read(messaging)
|
def read(messaging, channel)
|
||||||
::Instagram::ReadStatusService.new(params: messaging).perform
|
# Use a single service to handle read status for both channel types since the params are same
|
||||||
|
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def messages(entry)
|
def messages(entry)
|
||||||
(entry[:messaging].presence || entry[:standby] || [])
|
(entry[:messaging].presence || entry[:standby] || [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "time": <timestamp>,
|
||||||
|
# "id": <INSTAGRAM_USER_ID>,
|
||||||
|
# "messaging": [
|
||||||
|
# {
|
||||||
|
# "sender": {
|
||||||
|
# "id": <INSTAGRAM_USER_ID>
|
||||||
|
# },
|
||||||
|
# "recipient": {
|
||||||
|
# "id": <INSTAGRAM_USER_ID>
|
||||||
|
# },
|
||||||
|
# "timestamp": <timestamp>,
|
||||||
|
# "message": {
|
||||||
|
# "mid": <MESSAGE_ID>,
|
||||||
|
# "text": <MESSAGE_TEXT>
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
|
||||||
|
# When testing in Facebook's developer dashboard, you'll get a Page-style
|
||||||
|
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
|
||||||
|
# arrive in the familiar Messenger format with a "messaging" array.
|
||||||
|
# This apparent inconsistency is actually by design - Instagram's webhooks
|
||||||
|
# use different formats for testing vs production to maintain compatibility
|
||||||
|
# with both Instagram Direct and Facebook Page integrations.
|
||||||
|
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
|
||||||
|
|
||||||
|
# Test response from via Instagram direct
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "0",
|
||||||
|
# "time": <timestamp>,
|
||||||
|
# "changes": [
|
||||||
|
# {
|
||||||
|
# "field": "messages",
|
||||||
|
# "value": {
|
||||||
|
# "sender": {
|
||||||
|
# "id": "12334"
|
||||||
|
# },
|
||||||
|
# "recipient": {
|
||||||
|
# "id": "23245"
|
||||||
|
# },
|
||||||
|
# "timestamp": "1527459824",
|
||||||
|
# "message": {
|
||||||
|
# "mid": "random_mid",
|
||||||
|
# "text": "random_text"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Test response via Facebook page
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "time": <timestamp>,,
|
||||||
|
# "id": "0",
|
||||||
|
# "messaging": [
|
||||||
|
# {
|
||||||
|
# "sender": {
|
||||||
|
# "id": "12334"
|
||||||
|
# },
|
||||||
|
# "recipient": {
|
||||||
|
# "id": "23245"
|
||||||
|
# },
|
||||||
|
# "timestamp": <timestamp>,
|
||||||
|
# "message": {
|
||||||
|
# "mid": "random_mid",
|
||||||
|
# "text": "random_text"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
|||||||
@ -31,6 +31,14 @@ class Channel::Instagram < ApplicationRecord
|
|||||||
'Instagram'
|
'Instagram'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_contact_inbox(instagram_id, name)
|
||||||
|
@contact_inbox = ::ContactInboxWithContactBuilder.new({
|
||||||
|
source_id: instagram_id,
|
||||||
|
inbox: inbox,
|
||||||
|
contact_attributes: { name: name }
|
||||||
|
}).perform
|
||||||
|
end
|
||||||
|
|
||||||
def subscribe
|
def subscribe
|
||||||
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
|
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
|
||||||
HTTParty.post(
|
HTTParty.post(
|
||||||
|
|||||||
69
app/services/instagram/base_message_text.rb
Normal file
69
app/services/instagram/base_message_text.rb
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
|
||||||
|
attr_reader :messaging
|
||||||
|
|
||||||
|
def initialize(messaging, channel)
|
||||||
|
@messaging = messaging
|
||||||
|
super(channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
connected_instagram_id, contact_id = instagram_and_contact_ids
|
||||||
|
inbox_channel(connected_instagram_id)
|
||||||
|
|
||||||
|
return if @inbox.blank?
|
||||||
|
|
||||||
|
if @inbox.channel.reauthorization_required?
|
||||||
|
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
return unsend_message if message_is_deleted?
|
||||||
|
|
||||||
|
ensure_contact(contact_id) if contacts_first_message?(contact_id)
|
||||||
|
|
||||||
|
create_message
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def instagram_and_contact_ids
|
||||||
|
if agent_message_via_echo?
|
||||||
|
[@messaging[:sender][:id], @messaging[:recipient][:id]]
|
||||||
|
else
|
||||||
|
[@messaging[:recipient][:id], @messaging[:sender][:id]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_message_via_echo?
|
||||||
|
@messaging[:message][:is_echo].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_is_deleted?
|
||||||
|
@messaging[:message][:is_deleted].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# if contact was present before find out contact_inbox to create message
|
||||||
|
def contacts_first_message?(ig_scope_id)
|
||||||
|
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
|
||||||
|
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsend_message
|
||||||
|
message_to_delete = @inbox.messages.find_by(
|
||||||
|
source_id: @messaging[:message][:mid]
|
||||||
|
)
|
||||||
|
return if message_to_delete.blank?
|
||||||
|
|
||||||
|
message_to_delete.attachments.destroy_all
|
||||||
|
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Methods to be implemented by subclasses
|
||||||
|
def ensure_contact(contact_id)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_message
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #create_message"
|
||||||
|
end
|
||||||
|
end
|
||||||
95
app/services/instagram/base_send_service.rb
Normal file
95
app/services/instagram/base_send_service.rb
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
class Instagram::BaseSendService < Base::SendOnChannelService
|
||||||
|
pattr_initialize [:message!]
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
delegate :additional_attributes, to: :contact
|
||||||
|
|
||||||
|
def perform_reply
|
||||||
|
send_attachments if message.attachments.present?
|
||||||
|
send_content if message.content.present?
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_attachments
|
||||||
|
message.attachments.each do |attachment|
|
||||||
|
send_message(attachment_message_params(attachment))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_content
|
||||||
|
send_message(message_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_error(error)
|
||||||
|
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_params
|
||||||
|
params = {
|
||||||
|
recipient: { id: contact.get_source_id(inbox.id) },
|
||||||
|
message: {
|
||||||
|
text: message.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_human_agent_tag(params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_message_params(attachment)
|
||||||
|
params = {
|
||||||
|
recipient: { id: contact.get_source_id(inbox.id) },
|
||||||
|
message: {
|
||||||
|
attachment: {
|
||||||
|
type: attachment_type(attachment),
|
||||||
|
payload: {
|
||||||
|
url: attachment.download_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_human_agent_tag(params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_response(response, message_content)
|
||||||
|
parsed_response = response.parsed_response
|
||||||
|
if response.success? && parsed_response['error'].blank?
|
||||||
|
message.update!(source_id: parsed_response['message_id'])
|
||||||
|
parsed_response
|
||||||
|
else
|
||||||
|
external_error = external_error(parsed_response)
|
||||||
|
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
|
||||||
|
message.update!(status: :failed, external_error: external_error)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def external_error(response)
|
||||||
|
error_message = response.dig('error', 'message')
|
||||||
|
error_code = response.dig('error', 'code')
|
||||||
|
|
||||||
|
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||||
|
# Access token has expired or become invalid. This may be due to a password change,
|
||||||
|
# removal of the connected app from Instagram account settings, or other reasons.
|
||||||
|
channel.authorization_error! if error_code == 190
|
||||||
|
|
||||||
|
"#{error_code} - #{error_message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_type(attachment)
|
||||||
|
return attachment.file_type if %w[image audio video file].include? attachment.file_type
|
||||||
|
|
||||||
|
'file'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Methods to be implemented by child classes
|
||||||
|
def send_message(message_content)
|
||||||
|
raise NotImplementedError, 'Subclasses must implement send_message'
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_human_agent_tag(params)
|
||||||
|
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,90 +1,54 @@
|
|||||||
class Instagram::MessageText < Instagram::WebhooksBaseService
|
class Instagram::MessageText < Instagram::BaseMessageText
|
||||||
include HTTParty
|
|
||||||
|
|
||||||
attr_reader :messaging
|
attr_reader :messaging
|
||||||
|
|
||||||
base_uri 'https://graph.facebook.com/v11.0/'
|
|
||||||
|
|
||||||
def initialize(messaging)
|
|
||||||
super()
|
|
||||||
@messaging = messaging
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform
|
|
||||||
create_test_text
|
|
||||||
instagram_id, contact_id = instagram_and_contact_ids
|
|
||||||
inbox_channel(instagram_id)
|
|
||||||
# person can connect the channel and then delete the inbox
|
|
||||||
return if @inbox.blank?
|
|
||||||
|
|
||||||
# This channel might require reauthorization, may be owner might have changed the fb password
|
|
||||||
if @inbox.channel.reauthorization_required?
|
|
||||||
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return unsend_message if message_is_deleted?
|
|
||||||
|
|
||||||
ensure_contact(contact_id) if contacts_first_message?(contact_id)
|
|
||||||
|
|
||||||
create_message
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def instagram_and_contact_ids
|
|
||||||
if agent_message_via_echo?
|
|
||||||
[@messaging[:sender][:id], @messaging[:recipient][:id]]
|
|
||||||
else
|
|
||||||
[@messaging[:recipient][:id], @messaging[:sender][:id]]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# rubocop:disable Metrics/AbcSize
|
|
||||||
def ensure_contact(ig_scope_id)
|
def ensure_contact(ig_scope_id)
|
||||||
begin
|
result = fetch_instagram_user(ig_scope_id)
|
||||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
find_or_create_contact(result) if result.present?
|
||||||
result = k.get_object(ig_scope_id) || {}
|
|
||||||
rescue Koala::Facebook::AuthenticationError => e
|
|
||||||
@inbox.channel.authorization_error!
|
|
||||||
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
|
|
||||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
|
||||||
rescue StandardError, Koala::Facebook::ClientError => e
|
|
||||||
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
|
||||||
Rails.logger.warn("[FacebookUserFetchClientError]: #{e.message}")
|
|
||||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
|
||||||
end
|
|
||||||
|
|
||||||
find_or_create_contact(result) if defined?(result) && result.present?
|
|
||||||
end
|
|
||||||
# rubocop:enable Metrics/AbcSize
|
|
||||||
|
|
||||||
def agent_message_via_echo?
|
|
||||||
@messaging[:message][:is_echo].present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_is_deleted?
|
def fetch_instagram_user(ig_scope_id)
|
||||||
@messaging[:message][:is_deleted].present?
|
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
|
||||||
|
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
|
||||||
|
|
||||||
|
response = HTTParty.get(url)
|
||||||
|
|
||||||
|
return process_successful_response(response) if response.success?
|
||||||
|
|
||||||
|
handle_error_response(response)
|
||||||
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
# if contact was present before find out contact_inbox to create message
|
def process_successful_response(response)
|
||||||
def contacts_first_message?(ig_scope_id)
|
result = JSON.parse(response.body).with_indifferent_access
|
||||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
|
{
|
||||||
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
|
'name' => result['name'],
|
||||||
|
'username' => result['username'],
|
||||||
|
'profile_pic' => result['profile_pic'],
|
||||||
|
'id' => result['id'],
|
||||||
|
'follower_count' => result['follower_count'],
|
||||||
|
'is_user_follow_business' => result['is_user_follow_business'],
|
||||||
|
'is_business_follow_user' => result['is_business_follow_user'],
|
||||||
|
'is_verified_user' => result['is_verified_user']
|
||||||
|
}.with_indifferent_access
|
||||||
end
|
end
|
||||||
|
|
||||||
def sent_via_test_webhook?
|
def handle_error_response(response)
|
||||||
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
|
parsed_response = response.parsed_response
|
||||||
|
error_message = parsed_response.dig('error', 'message')
|
||||||
|
error_code = parsed_response.dig('error', 'code')
|
||||||
|
|
||||||
|
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||||
|
# Access token has expired or become invalid.
|
||||||
|
channel.authorization_error! if error_code == 190
|
||||||
|
|
||||||
|
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||||
|
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
|
||||||
|
|
||||||
|
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||||
end
|
end
|
||||||
|
|
||||||
def unsend_message
|
def base_uri
|
||||||
message_to_delete = @inbox.messages.find_by(
|
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
|
||||||
source_id: @messaging[:message][:mid]
|
|
||||||
)
|
|
||||||
return if message_to_delete.blank?
|
|
||||||
|
|
||||||
message_to_delete.attachments.destroy_all
|
|
||||||
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_message
|
def create_message
|
||||||
@ -92,65 +56,4 @@ class Instagram::MessageText < Instagram::WebhooksBaseService
|
|||||||
|
|
||||||
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
|
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_test_text
|
|
||||||
return unless sent_via_test_webhook?
|
|
||||||
|
|
||||||
Rails.logger.info('Probably Test data.')
|
|
||||||
|
|
||||||
messenger_channel = Channel::FacebookPage.last
|
|
||||||
@inbox = ::Inbox.find_by(channel: messenger_channel)
|
|
||||||
return unless @inbox
|
|
||||||
|
|
||||||
@contact = create_test_contact
|
|
||||||
|
|
||||||
@conversation ||= create_test_conversation(conversation_params)
|
|
||||||
|
|
||||||
@message = @conversation.messages.create!(test_message_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_test_contact
|
|
||||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
|
|
||||||
unless @contact_inbox
|
|
||||||
@contact_inbox ||= @inbox.channel.create_contact_inbox(
|
|
||||||
'sender_username', 'sender_username'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@contact_inbox.contact
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_test_conversation(conversation_params)
|
|
||||||
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_message_params
|
|
||||||
{
|
|
||||||
account_id: @conversation.account_id,
|
|
||||||
inbox_id: @conversation.inbox_id,
|
|
||||||
message_type: 'incoming',
|
|
||||||
source_id: @messaging[:message][:mid],
|
|
||||||
content: @messaging[:message][:text],
|
|
||||||
sender: @contact
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_conversation(conversation_params)
|
|
||||||
Conversation.create!(
|
|
||||||
conversation_params.merge(
|
|
||||||
contact_inbox_id: @contact_inbox.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_params
|
|
||||||
{
|
|
||||||
account_id: @inbox.account_id,
|
|
||||||
inbox_id: @inbox.id,
|
|
||||||
contact_id: @contact.id,
|
|
||||||
additional_attributes: {
|
|
||||||
type: 'instagram_direct_message'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
37
app/services/instagram/messenger/message_text.rb
Normal file
37
app/services/instagram/messenger/message_text.rb
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_contact(ig_scope_id)
|
||||||
|
result = fetch_instagram_user(ig_scope_id)
|
||||||
|
find_or_create_contact(result) if result.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_instagram_user(ig_scope_id)
|
||||||
|
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||||
|
k.get_object(ig_scope_id) || {}
|
||||||
|
rescue Koala::Facebook::AuthenticationError => e
|
||||||
|
handle_authentication_error(e)
|
||||||
|
{}
|
||||||
|
rescue StandardError, Koala::Facebook::ClientError => e
|
||||||
|
handle_client_error(e)
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authentication_error(error)
|
||||||
|
@inbox.channel.authorization_error!
|
||||||
|
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
|
||||||
|
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_client_error(error)
|
||||||
|
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||||
|
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
|
||||||
|
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_message
|
||||||
|
return unless @contact_inbox
|
||||||
|
|
||||||
|
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
|
||||||
|
private
|
||||||
|
|
||||||
|
def channel_class
|
||||||
|
Channel::FacebookPage
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deliver a message with the given payload.
|
||||||
|
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
|
||||||
|
def send_message(message_content)
|
||||||
|
access_token = channel.page_access_token
|
||||||
|
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
|
||||||
|
query = { access_token: access_token }
|
||||||
|
query[:appsecret_proof] = app_secret_proof if app_secret_proof
|
||||||
|
|
||||||
|
response = HTTParty.post(
|
||||||
|
'https://graph.facebook.com/v11.0/me/messages',
|
||||||
|
body: message_content,
|
||||||
|
query: query
|
||||||
|
)
|
||||||
|
|
||||||
|
process_response(response, message_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_app_secret_proof(app_secret, access_token)
|
||||||
|
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
|
||||||
|
app_secret, access_token
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_human_agent_tag(params)
|
||||||
|
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||||
|
|
||||||
|
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
||||||
|
|
||||||
|
params[:messaging_type] = 'MESSAGE_TAG'
|
||||||
|
params[:tag] = 'HUMAN_AGENT'
|
||||||
|
params
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,8 +1,8 @@
|
|||||||
class Instagram::ReadStatusService
|
class Instagram::ReadStatusService
|
||||||
pattr_initialize [:params!]
|
pattr_initialize [:params!, :channel!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
return if instagram_channel.blank?
|
return if channel.blank?
|
||||||
|
|
||||||
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
|
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
|
||||||
end
|
end
|
||||||
@ -11,13 +11,9 @@ class Instagram::ReadStatusService
|
|||||||
params[:recipient][:id]
|
params[:recipient][:id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def instagram_channel
|
|
||||||
@instagram_channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def message
|
def message
|
||||||
return unless params[:read][:mid]
|
return unless params[:read][:mid]
|
||||||
|
|
||||||
@message ||= @instagram_channel.inbox.messages.find_by(source_id: params[:read][:mid])
|
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
|
|||||||
token_is_valid = Time.current < channel.expires_at
|
token_is_valid = Time.current < channel.expires_at
|
||||||
|
|
||||||
# 2. Token is at least 24 hours old (based on updated_at)
|
# 2. Token is at least 24 hours old (based on updated_at)
|
||||||
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
|
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
|
||||||
|
|
||||||
# 3. Token is approaching expiry (within 10 days)
|
# 3. Token is approaching expiry (within 10 days)
|
||||||
approaching_expiry = channel.expires_at < 10.days.from_now
|
approaching_expiry = channel.expires_at < 10.days.from_now
|
||||||
|
|||||||
@ -1,130 +1,30 @@
|
|||||||
class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
class Instagram::SendOnInstagramService < Instagram::BaseSendService
|
||||||
include HTTParty
|
|
||||||
|
|
||||||
pattr_initialize [:message!]
|
|
||||||
|
|
||||||
base_uri 'https://graph.facebook.com/v11.0/me'
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
delegate :additional_attributes, to: :contact
|
|
||||||
|
|
||||||
def channel_class
|
def channel_class
|
||||||
Channel::FacebookPage
|
Channel::Instagram
|
||||||
end
|
|
||||||
|
|
||||||
def perform_reply
|
|
||||||
if message.attachments.present?
|
|
||||||
message.attachments.each do |attachment|
|
|
||||||
send_to_facebook_page attachment_message_params(attachment)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
send_to_facebook_page message_params if message.content.present?
|
|
||||||
rescue StandardError => e
|
|
||||||
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
|
|
||||||
# TODO : handle specific errors or else page will get disconnected
|
|
||||||
# channel.authorization_error!
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_params
|
|
||||||
params = {
|
|
||||||
recipient: { id: contact.get_source_id(inbox.id) },
|
|
||||||
message: {
|
|
||||||
text: message.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
merge_human_agent_tag(params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def attachment_message_params(attachment)
|
|
||||||
params = {
|
|
||||||
recipient: { id: contact.get_source_id(inbox.id) },
|
|
||||||
message: {
|
|
||||||
attachment: {
|
|
||||||
type: attachment_type(attachment),
|
|
||||||
payload: {
|
|
||||||
url: attachment.download_url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
merge_human_agent_tag(params)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Deliver a message with the given payload.
|
# Deliver a message with the given payload.
|
||||||
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
|
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
|
||||||
def send_to_facebook_page(message_content)
|
def send_message(message_content)
|
||||||
access_token = channel.page_access_token
|
access_token = channel.access_token
|
||||||
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
|
|
||||||
query = { access_token: access_token }
|
query = { access_token: access_token }
|
||||||
query[:appsecret_proof] = app_secret_proof if app_secret_proof
|
instagram_id = channel.instagram_id.presence || 'me'
|
||||||
|
|
||||||
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
|
|
||||||
|
|
||||||
response = HTTParty.post(
|
response = HTTParty.post(
|
||||||
'https://graph.facebook.com/v11.0/me/messages',
|
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
|
||||||
body: message_content,
|
body: message_content,
|
||||||
query: query
|
query: query
|
||||||
)
|
)
|
||||||
|
|
||||||
handle_response(response, message_content)
|
process_response(response, message_content)
|
||||||
end
|
|
||||||
|
|
||||||
def handle_response(response, message_content)
|
|
||||||
parsed_response = response.parsed_response
|
|
||||||
if response.success? && parsed_response['error'].blank?
|
|
||||||
message.update!(source_id: parsed_response['message_id'])
|
|
||||||
|
|
||||||
parsed_response
|
|
||||||
else
|
|
||||||
external_error = external_error(parsed_response)
|
|
||||||
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
|
|
||||||
message.update!(status: :failed, external_error: external_error)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def external_error(response)
|
|
||||||
# https://developers.facebook.com/docs/instagram-api/reference/error-codes/
|
|
||||||
error_message = response.dig('error', 'message')
|
|
||||||
error_code = response.dig('error', 'code')
|
|
||||||
|
|
||||||
"#{error_code} - #{error_message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate_app_secret_proof(app_secret, access_token)
|
|
||||||
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
|
|
||||||
app_secret, access_token
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def attachment_type(attachment)
|
|
||||||
return attachment.file_type if %w[image audio video file].include? attachment.file_type
|
|
||||||
|
|
||||||
'file'
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_type
|
|
||||||
conversation.additional_attributes['type']
|
|
||||||
end
|
|
||||||
|
|
||||||
def sent_first_outgoing_message_after_24_hours?
|
|
||||||
# we can send max 1 message after 24 hour window
|
|
||||||
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def config
|
|
||||||
Facebook::Messenger.config
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_human_agent_tag(params)
|
def merge_human_agent_tag(params)
|
||||||
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
|
||||||
|
|
||||||
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
|
||||||
|
|
||||||
params[:messaging_type] = 'MESSAGE_TAG'
|
params[:messaging_type] = 'MESSAGE_TAG'
|
||||||
params[:tag] = 'HUMAN_AGENT'
|
params[:tag] = 'HUMAN_AGENT'
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
class Instagram::WebhooksBaseService
|
class Instagram::WebhooksBaseService
|
||||||
|
attr_reader :channel
|
||||||
|
|
||||||
|
def initialize(channel)
|
||||||
|
@channel = channel
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def inbox_channel(instagram_id)
|
def inbox_channel(_instagram_id)
|
||||||
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
|
@inbox = ::Inbox.find_by(channel: @channel)
|
||||||
@inbox = ::Inbox.find_by(channel: messenger_channel)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_contact(user)
|
def find_or_create_contact(user)
|
||||||
@ -24,9 +29,31 @@ class Instagram::WebhooksBaseService
|
|||||||
def update_instagram_profile_link(user)
|
def update_instagram_profile_link(user)
|
||||||
return unless user['username']
|
return unless user['username']
|
||||||
|
|
||||||
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
instagram_attributes = build_instagram_attributes(user)
|
||||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
|
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
|
||||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
|
end
|
||||||
@contact.save!
|
|
||||||
|
def build_instagram_attributes(user)
|
||||||
|
attributes = {
|
||||||
|
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
||||||
|
'social_profiles': { 'instagram': user['username'] },
|
||||||
|
'social_instagram_user_name': user['username']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional attributes if present
|
||||||
|
optional_fields = %w[
|
||||||
|
follower_count
|
||||||
|
is_user_follow_business
|
||||||
|
is_business_follow_user
|
||||||
|
is_verified_user
|
||||||
|
]
|
||||||
|
|
||||||
|
optional_fields.each do |field|
|
||||||
|
next if user[field].nil?
|
||||||
|
|
||||||
|
attributes["social_instagram_#{field}"] = user[field]
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -85,15 +85,15 @@ linear:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-linear'
|
icon: 'icon-linear'
|
||||||
config_key: 'linear'
|
config_key: 'linear'
|
||||||
shopify:
|
|
||||||
name: 'Shopify'
|
|
||||||
description: 'Configuration for setting up Shopify'
|
|
||||||
enabled: true
|
|
||||||
icon: 'icon-shopify'
|
|
||||||
config_key: 'shopify'
|
|
||||||
instagram:
|
instagram:
|
||||||
name: 'Instagram'
|
name: 'Instagram'
|
||||||
description: 'Configuration for setting up Instagram'
|
description: 'Configuration for setting up Instagram'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
|
shopify:
|
||||||
|
name: 'Shopify'
|
||||||
|
description: 'Configuration for setting up Shopify'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-shopify'
|
||||||
|
config_key: 'shopify'
|
||||||
|
|||||||
@ -138,7 +138,7 @@
|
|||||||
"prosemirror-model": "^1.22.3",
|
"prosemirror-model": "^1.22.3",
|
||||||
"size-limit": "^8.2.4",
|
"size-limit": "^8.2.4",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"vite": "^5.4.15",
|
"vite": "^5.4.17",
|
||||||
"vite-plugin-ruby": "^5.0.0",
|
"vite-plugin-ruby": "^5.0.0",
|
||||||
"vitest": "3.0.5"
|
"vitest": "3.0.5"
|
||||||
},
|
},
|
||||||
@ -154,7 +154,7 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite-node": "2.0.1",
|
"vite-node": "2.0.1",
|
||||||
"vite": "5.4.15",
|
"vite": "5.4.17",
|
||||||
"vitest": "3.0.5"
|
"vitest": "3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
255
pnpm-lock.yaml
generated
255
pnpm-lock.yaml
generated
@ -6,7 +6,7 @@ settings:
|
|||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
vite-node: 2.0.1
|
vite-node: 2.0.1
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
vitest: 3.0.5
|
vitest: 3.0.5
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@ -72,7 +72,7 @@ importers:
|
|||||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
version: 5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||||
'@vue/compiler-sfc':
|
'@vue/compiler-sfc':
|
||||||
specifier: ^3.5.8
|
specifier: ^3.5.8
|
||||||
version: 3.5.8
|
version: 3.5.8
|
||||||
@ -238,7 +238,7 @@ importers:
|
|||||||
version: 1.8.1(tailwindcss@3.4.13)
|
version: 1.8.1(tailwindcss@3.4.13)
|
||||||
'@histoire/plugin-vue':
|
'@histoire/plugin-vue':
|
||||||
specifier: 0.17.15
|
specifier: 0.17.15
|
||||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||||
'@iconify-json/logos':
|
'@iconify-json/logos':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
@ -301,7 +301,7 @@ importers:
|
|||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
histoire:
|
histoire:
|
||||||
specifier: 0.17.15
|
specifier: 0.17.15
|
||||||
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
husky:
|
husky:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.4
|
version: 7.0.4
|
||||||
@ -330,11 +330,11 @@ importers:
|
|||||||
specifier: ^3.4.13
|
specifier: ^3.4.13
|
||||||
version: 3.4.13
|
version: 3.4.13
|
||||||
vite:
|
vite:
|
||||||
specifier: 5.4.15
|
specifier: 5.4.17
|
||||||
version: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
version: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
vite-plugin-ruby:
|
vite-plugin-ruby:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
version: 5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 3.0.5
|
specifier: 3.0.5
|
||||||
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
|
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
|
||||||
@ -860,7 +860,7 @@ packages:
|
|||||||
'@histoire/shared@0.17.17':
|
'@histoire/shared@0.17.17':
|
||||||
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
|
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
|
|
||||||
'@histoire/vendors@0.17.17':
|
'@histoire/vendors@0.17.17':
|
||||||
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
|
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
|
||||||
@ -1039,103 +1039,103 @@ packages:
|
|||||||
'@rails/ujs@7.1.400':
|
'@rails/ujs@7.1.400':
|
||||||
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.37.0':
|
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||||
resolution: {integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==}
|
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.37.0':
|
'@rollup/rollup-android-arm64@4.39.0':
|
||||||
resolution: {integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==}
|
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.37.0':
|
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||||
resolution: {integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==}
|
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.37.0':
|
'@rollup/rollup-darwin-x64@4.39.0':
|
||||||
resolution: {integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==}
|
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.37.0':
|
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||||
resolution: {integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==}
|
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.37.0':
|
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||||
resolution: {integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==}
|
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||||
resolution: {integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==}
|
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||||
resolution: {integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==}
|
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.37.0':
|
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==}
|
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.37.0':
|
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||||
resolution: {integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==}
|
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
|
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==}
|
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==}
|
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==}
|
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.37.0':
|
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||||
resolution: {integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==}
|
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.37.0':
|
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==}
|
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.37.0':
|
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||||
resolution: {integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==}
|
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.37.0':
|
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||||
resolution: {integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==}
|
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.37.0':
|
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||||
resolution: {integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==}
|
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.37.0':
|
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||||
resolution: {integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==}
|
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.37.0':
|
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||||
resolution: {integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==}
|
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@ -1746,9 +1746,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@types/estree@1.0.6':
|
|
||||||
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
|
|
||||||
|
|
||||||
'@types/estree@1.0.7':
|
'@types/estree@1.0.7':
|
||||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||||
|
|
||||||
@ -1803,7 +1800,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
|
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.0.5':
|
'@vitest/coverage-v8@3.0.5':
|
||||||
@ -1822,7 +1819,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
|
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
msw: ^2.4.9
|
msw: ^2.4.9
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
msw:
|
msw:
|
||||||
optional: true
|
optional: true
|
||||||
@ -3103,7 +3100,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
|
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
|
|
||||||
hotkeys-js@3.8.7:
|
hotkeys-js@3.8.7:
|
||||||
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
|
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
|
||||||
@ -4347,8 +4344,8 @@ packages:
|
|||||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
rollup@4.37.0:
|
rollup@4.39.0:
|
||||||
resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==}
|
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@ -4866,10 +4863,10 @@ packages:
|
|||||||
vite-plugin-ruby@5.0.0:
|
vite-plugin-ruby@5.0.0:
|
||||||
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
|
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: 5.4.15
|
vite: 5.4.17
|
||||||
|
|
||||||
vite@5.4.15:
|
vite@5.4.17:
|
||||||
resolution: {integrity: sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==}
|
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5675,10 +5672,10 @@ snapshots:
|
|||||||
highlight.js: 11.10.0
|
highlight.js: 11.10.0
|
||||||
vue: 3.5.12(typescript@5.6.2)
|
vue: 3.5.12(typescript@5.6.2)
|
||||||
|
|
||||||
'@histoire/app@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
'@histoire/app@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/vendors': 0.17.17
|
'@histoire/vendors': 0.17.17
|
||||||
'@types/flexsearch': 0.7.6
|
'@types/flexsearch': 0.7.6
|
||||||
flexsearch: 0.7.21
|
flexsearch: 0.7.21
|
||||||
@ -5686,7 +5683,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@histoire/controls@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
'@histoire/controls@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/commands': 6.7.0
|
'@codemirror/commands': 6.7.0
|
||||||
'@codemirror/lang-json': 6.0.1
|
'@codemirror/lang-json': 6.0.1
|
||||||
@ -5695,26 +5692,26 @@ snapshots:
|
|||||||
'@codemirror/state': 6.4.1
|
'@codemirror/state': 6.4.1
|
||||||
'@codemirror/theme-one-dark': 6.1.2
|
'@codemirror/theme-one-dark': 6.1.2
|
||||||
'@codemirror/view': 6.34.1
|
'@codemirror/view': 6.34.1
|
||||||
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/vendors': 0.17.17
|
'@histoire/vendors': 0.17.17
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/vendors': 0.17.17
|
'@histoire/vendors': 0.17.17
|
||||||
change-case: 4.1.2
|
change-case: 4.1.2
|
||||||
globby: 13.2.2
|
globby: 13.2.2
|
||||||
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
launch-editor: 2.9.1
|
launch-editor: 2.9.1
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vue: 3.5.12(typescript@5.6.2)
|
vue: 3.5.12(typescript@5.6.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@histoire/shared@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
'@histoire/shared@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@histoire/vendors': 0.17.17
|
'@histoire/vendors': 0.17.17
|
||||||
'@types/fs-extra': 9.0.13
|
'@types/fs-extra': 9.0.13
|
||||||
@ -5722,7 +5719,7 @@ snapshots:
|
|||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
picocolors: 1.1.0
|
picocolors: 1.1.0
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
|
|
||||||
'@histoire/vendors@0.17.17': {}
|
'@histoire/vendors@0.17.17': {}
|
||||||
|
|
||||||
@ -5947,64 +5944,64 @@ snapshots:
|
|||||||
|
|
||||||
'@rails/ujs@7.1.400': {}
|
'@rails/ujs@7.1.400': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.37.0':
|
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.37.0':
|
'@rollup/rollup-android-arm64@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.37.0':
|
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.37.0':
|
'@rollup/rollup-darwin-x64@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.37.0':
|
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.37.0':
|
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.37.0':
|
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.37.0':
|
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
|
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.37.0':
|
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.37.0':
|
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.37.0':
|
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.37.0':
|
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.37.0':
|
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.37.0':
|
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.37.0':
|
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
@ -6747,8 +6744,6 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/once@2.0.0': {}
|
'@tootallnate/once@2.0.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.6': {}
|
|
||||||
|
|
||||||
'@types/estree@1.0.7': {}
|
'@types/estree@1.0.7': {}
|
||||||
|
|
||||||
'@types/flexsearch@0.7.6': {}
|
'@types/flexsearch@0.7.6': {}
|
||||||
@ -6807,9 +6802,9 @@ snapshots:
|
|||||||
global: 4.4.0
|
global: 4.4.0
|
||||||
is-function: 1.0.2
|
is-function: 1.0.2
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.1.4(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
'@vitejs/plugin-vue@5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
vue: 3.5.12(typescript@5.6.2)
|
vue: 3.5.12(typescript@5.6.2)
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
|
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
|
||||||
@ -6837,13 +6832,13 @@ snapshots:
|
|||||||
chai: 5.1.2
|
chai: 5.1.2
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
'@vitest/mocker@3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.0.5
|
'@vitest/spy': 3.0.5
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.0.5':
|
'@vitest/pretty-format@3.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8398,12 +8393,12 @@ snapshots:
|
|||||||
|
|
||||||
highlight.js@11.10.0: {}
|
highlight.js@11.10.0: {}
|
||||||
|
|
||||||
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@akryum/tinypool': 0.3.1
|
'@akryum/tinypool': 0.3.1
|
||||||
'@histoire/app': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/app': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@histoire/vendors': 0.17.17
|
'@histoire/vendors': 0.17.17
|
||||||
'@types/flexsearch': 0.7.6
|
'@types/flexsearch': 0.7.6
|
||||||
'@types/markdown-it': 12.2.3
|
'@types/markdown-it': 12.2.3
|
||||||
@ -8430,7 +8425,7 @@ snapshots:
|
|||||||
sade: 1.8.1
|
sade: 1.8.1
|
||||||
shiki-es: 0.2.0
|
shiki-es: 0.2.0
|
||||||
sirv: 2.0.4
|
sirv: 2.0.4
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
@ -9771,30 +9766,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
|
|
||||||
rollup@4.37.0:
|
rollup@4.39.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rollup/rollup-android-arm-eabi': 4.37.0
|
'@rollup/rollup-android-arm-eabi': 4.39.0
|
||||||
'@rollup/rollup-android-arm64': 4.37.0
|
'@rollup/rollup-android-arm64': 4.39.0
|
||||||
'@rollup/rollup-darwin-arm64': 4.37.0
|
'@rollup/rollup-darwin-arm64': 4.39.0
|
||||||
'@rollup/rollup-darwin-x64': 4.37.0
|
'@rollup/rollup-darwin-x64': 4.39.0
|
||||||
'@rollup/rollup-freebsd-arm64': 4.37.0
|
'@rollup/rollup-freebsd-arm64': 4.39.0
|
||||||
'@rollup/rollup-freebsd-x64': 4.37.0
|
'@rollup/rollup-freebsd-x64': 4.39.0
|
||||||
'@rollup/rollup-linux-arm-gnueabihf': 4.37.0
|
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
|
||||||
'@rollup/rollup-linux-arm-musleabihf': 4.37.0
|
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
|
||||||
'@rollup/rollup-linux-arm64-gnu': 4.37.0
|
'@rollup/rollup-linux-arm64-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-arm64-musl': 4.37.0
|
'@rollup/rollup-linux-arm64-musl': 4.39.0
|
||||||
'@rollup/rollup-linux-loongarch64-gnu': 4.37.0
|
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.37.0
|
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-riscv64-gnu': 4.37.0
|
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-riscv64-musl': 4.37.0
|
'@rollup/rollup-linux-riscv64-musl': 4.39.0
|
||||||
'@rollup/rollup-linux-s390x-gnu': 4.37.0
|
'@rollup/rollup-linux-s390x-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-x64-gnu': 4.37.0
|
'@rollup/rollup-linux-x64-gnu': 4.39.0
|
||||||
'@rollup/rollup-linux-x64-musl': 4.37.0
|
'@rollup/rollup-linux-x64-musl': 4.39.0
|
||||||
'@rollup/rollup-win32-arm64-msvc': 4.37.0
|
'@rollup/rollup-win32-arm64-msvc': 4.39.0
|
||||||
'@rollup/rollup-win32-ia32-msvc': 4.37.0
|
'@rollup/rollup-win32-ia32-msvc': 4.39.0
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.37.0
|
'@rollup/rollup-win32-x64-msvc': 4.39.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
rope-sequence@1.3.2: {}
|
rope-sequence@1.3.2: {}
|
||||||
@ -10383,7 +10378,7 @@ snapshots:
|
|||||||
debug: 4.4.0
|
debug: 4.4.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@ -10395,19 +10390,19 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
vite-plugin-ruby@5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
vite-plugin-ruby@5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.5
|
debug: 4.3.5
|
||||||
fast-glob: 3.3.2
|
fast-glob: 3.3.2
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.3
|
postcss: 8.5.3
|
||||||
rollup: 4.37.0
|
rollup: 4.39.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.7.0
|
'@types/node': 22.7.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
@ -10417,7 +10412,7 @@ snapshots:
|
|||||||
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
|
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 3.0.5
|
'@vitest/expect': 3.0.5
|
||||||
'@vitest/mocker': 3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
'@vitest/mocker': 3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||||
'@vitest/pretty-format': 3.0.5
|
'@vitest/pretty-format': 3.0.5
|
||||||
'@vitest/runner': 3.0.5
|
'@vitest/runner': 3.0.5
|
||||||
'@vitest/snapshot': 3.0.5
|
'@vitest/snapshot': 3.0.5
|
||||||
@ -10433,7 +10428,7 @@ snapshots:
|
|||||||
tinyexec: 0.3.2
|
tinyexec: 0.3.2
|
||||||
tinypool: 1.0.2
|
tinypool: 1.0.2
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
|||||||
BIN
public/assets/images/dashboard/channels/instagram.png
Normal file
BIN
public/assets/images/dashboard/channels/instagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@ -95,5 +95,33 @@ describe ContactInboxWithContactBuilder do
|
|||||||
|
|
||||||
expect(contact_inbox.contact.id).to be(contact.id)
|
expect(contact_inbox.contact.id).to be(contact.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
|
||||||
|
instagram_source_id = '123456789'
|
||||||
|
|
||||||
|
# Create a Facebook page inbox with a contact using the same source_id
|
||||||
|
facebook_inbox = create(:inbox, channel_type: 'Channel::FacebookPage', account: account)
|
||||||
|
facebook_contact = create(:contact, account: account)
|
||||||
|
facebook_contact_inbox = create(:contact_inbox, contact: facebook_contact, inbox: facebook_inbox, source_id: instagram_source_id)
|
||||||
|
|
||||||
|
# Create an Instagram inbox
|
||||||
|
instagram_inbox = create(:inbox, channel_type: 'Channel::Instagram', account: account)
|
||||||
|
|
||||||
|
# Try to create a contact inbox with same source_id for Instagram
|
||||||
|
contact_inbox = described_class.new(
|
||||||
|
source_id: instagram_source_id,
|
||||||
|
inbox: instagram_inbox,
|
||||||
|
contact_attributes: {
|
||||||
|
name: 'Instagram User',
|
||||||
|
email: 'instagram_user@example.com'
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
# Should reuse the existing contact from Facebook
|
||||||
|
expect(contact_inbox.contact.id).to eq(facebook_contact.id)
|
||||||
|
# Make sure the contact inbox is not the same as the Facebook contact inbox
|
||||||
|
expect(contact_inbox.id).not_to eq(facebook_contact_inbox.id)
|
||||||
|
expect(contact_inbox.inbox_id).to eq(instagram_inbox.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,27 +1,26 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
describe Messages::Instagram::MessageBuilder do
|
describe Messages::Instagram::MessageBuilder do
|
||||||
subject(:instagram_message_builder) { described_class }
|
subject(:instagram_direct_message_builder) { described_class }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:post, /graph.facebook.com/)
|
stub_request(:post, /graph\.instagram\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
|
.to_return(status: 200, body: '', headers: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||||
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
|
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
|
||||||
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
|
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
|
||||||
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
|
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
|
||||||
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
|
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
|
||||||
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
|
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
|
||||||
let(:fb_object) { double }
|
let!(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
|
||||||
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
|
let!(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
|
||||||
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
|
|
||||||
let(:conversation) do
|
let(:conversation) do
|
||||||
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id,
|
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
|
||||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
|
|
||||||
end
|
end
|
||||||
let(:message) do
|
let(:message) do
|
||||||
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
|
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
|
||||||
@ -29,16 +28,27 @@ describe Messages::Instagram::MessageBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
it 'creates contact and message for the facebook inbox' do
|
before do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
instagram_channel.update!(access_token: 'valid_instagram_token')
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
|
||||||
name: 'Jane',
|
.to_return(
|
||||||
id: 'Sender-id-1',
|
status: 200,
|
||||||
account_id: instagram_inbox.account_id,
|
body: {
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
name: 'Jane',
|
||||||
}.with_indifferent_access
|
username: 'some_user_name',
|
||||||
)
|
profile_pic: 'https://chatwoot-assets.local/sample.png',
|
||||||
|
id: 'Sender-id-1',
|
||||||
|
follower_count: 100,
|
||||||
|
is_user_follow_business: true,
|
||||||
|
is_business_follow_user: true,
|
||||||
|
is_verified_user: false
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates contact and message for the instagram direct inbox' do
|
||||||
messaging = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
contact_inbox
|
||||||
described_class.new(messaging, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
@ -48,30 +58,19 @@ describe Messages::Instagram::MessageBuilder do
|
|||||||
expect(instagram_inbox.conversations.count).to be 1
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
|
||||||
contact = instagram_channel.inbox.contacts.first
|
message = instagram_inbox.messages.first
|
||||||
message = instagram_channel.inbox.messages.first
|
|
||||||
|
|
||||||
expect(contact.name).to eq('Jane Dae')
|
|
||||||
expect(message.content).to eq('This is the first message from the customer')
|
expect(message.content).to eq('This is the first message from the customer')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'discard echo message already sent by chatwoot' do
|
it 'discard echo message already sent by chatwoot' do
|
||||||
|
conversation
|
||||||
message
|
message
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.count).to be 1
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{
|
|
||||||
name: 'Jane',
|
|
||||||
id: 'Sender-id-1',
|
|
||||||
account_id: instagram_inbox.account_id,
|
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
messaging = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
|
||||||
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
|
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_inbox.reload
|
||||||
@ -81,220 +80,244 @@ describe Messages::Instagram::MessageBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates message for shared reel' do
|
it 'creates message for shared reel' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{
|
|
||||||
name: 'Jane',
|
|
||||||
id: 'Sender-id-1',
|
|
||||||
account_id: instagram_inbox.account_id,
|
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
messaging = shared_reel_params[:entry][0]['messaging'][0]
|
messaging = shared_reel_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
|
||||||
described_class.new(messaging, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
message = instagram_channel.inbox.messages.first
|
message = instagram_inbox.messages.first
|
||||||
expect(message.attachments.first.file_type).to eq('ig_reel')
|
expect(message.attachments.first.file_type).to eq('ig_reel')
|
||||||
expect(message.attachments.first.external_url).to eq(
|
expect(message.attachments.first.external_url).to eq(
|
||||||
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
|
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates message with for reply with story id' do
|
it 'creates message with story id' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{
|
|
||||||
name: 'Jane',
|
|
||||||
id: 'Sender-id-1',
|
|
||||||
account_id: instagram_inbox.account_id,
|
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
story: {
|
||||||
|
mention: {
|
||||||
|
id: 'chatwoot-app-user-id-1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
username: instagram_inbox.channel.instagram_id
|
||||||
|
}
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||||
described_class.new(messaging, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
message = instagram_channel.inbox.messages.first
|
message = instagram_inbox.messages.first
|
||||||
|
|
||||||
expect(message.content).to eq('This is the story reply')
|
expect(message.content).to eq('This is the story reply')
|
||||||
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
|
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
|
||||||
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
|
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
|
||||||
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates message with for reply with mid' do
|
it 'creates message with reply to mid' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
# Create first message to ensure reply to is valid
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
first_messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
{
|
described_class.new(first_messaging, instagram_inbox).perform
|
||||||
name: 'Jane',
|
|
||||||
id: 'Sender-id-1',
|
|
||||||
account_id: instagram_inbox.account_id,
|
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
# create first message to ensure reply to is valid
|
|
||||||
first_message = dm_params[:entry][0]['messaging'][0]
|
|
||||||
contact_inbox
|
|
||||||
described_class.new(first_message, instagram_inbox).perform
|
|
||||||
|
|
||||||
# create the second message with the reply to mid set
|
# Create second message with reply to mid
|
||||||
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
|
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
described_class.new(messaging, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
first_message = instagram_channel.inbox.messages.first
|
|
||||||
message = instagram_channel.inbox.messages.last
|
|
||||||
|
|
||||||
expect(message.content).to eq('This is message with replyto mid')
|
first_message = instagram_inbox.messages.first
|
||||||
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
|
reply_message = instagram_inbox.messages.last
|
||||||
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
|
|
||||||
|
expect(reply_message.content).to eq('This is message with replyto mid')
|
||||||
|
expect(reply_message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises exception on deleted story' do
|
it 'handles deleted story' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
|
||||||
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
|
|
||||||
190,
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
|
||||||
'This Message has been deleted by the user or the business.'
|
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
|
||||||
))
|
|
||||||
|
|
||||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||||
contact_inbox
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
|
||||||
|
|
||||||
instagram_inbox.reload
|
message = instagram_inbox.messages.first
|
||||||
|
|
||||||
# we would have contact created, message created but the empty message because the story mention has been deleted later
|
|
||||||
# As they show it in instagram that this story is no longer available
|
|
||||||
# and external attachments link would be reachable
|
|
||||||
expect(instagram_inbox.conversations.count).to be 1
|
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
|
||||||
|
|
||||||
contact = instagram_channel.inbox.contacts.first
|
|
||||||
message = instagram_channel.inbox.messages.first
|
|
||||||
|
|
||||||
expect(contact.name).to eq('Jane Dae')
|
|
||||||
expect(message.content).to eq('This story is no longer available.')
|
expect(message.content).to eq('This story is no longer available.')
|
||||||
expect(message.attachments.count).to eq(0)
|
expect(message.attachments.count).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create message for unsupported file type' do
|
it 'does not create message for unsupported file type' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{
|
|
||||||
name: 'Jane',
|
|
||||||
id: 'Sender-id-1',
|
|
||||||
account_id: instagram_inbox.account_id,
|
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
||||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||||
contact_inbox
|
|
||||||
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
||||||
|
|
||||||
instagram_inbox.reload
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
|
|
||||||
# we would have contact created but message and attachments won't be created
|
|
||||||
expect(instagram_inbox.conversations.count).to be 0
|
|
||||||
expect(instagram_inbox.messages.count).to be 0
|
expect(instagram_inbox.messages.count).to be 0
|
||||||
|
end
|
||||||
|
|
||||||
contact = instagram_channel.inbox.contacts.first
|
it 'does not create message if the message is already exists' do
|
||||||
|
message
|
||||||
|
|
||||||
expect(contact.name).to eq('Jane Dae')
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
|
||||||
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
|
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
|
||||||
|
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
||||||
|
|
||||||
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles authorization errors' do
|
||||||
|
instagram_channel.update!(access_token: 'invalid_token')
|
||||||
|
|
||||||
|
# Stub the request to return authorization error status
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
|
||||||
|
.to_return(
|
||||||
|
status: 401,
|
||||||
|
body: { error: { message: 'unauthorized access token', code: 190 } }.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
|
|
||||||
|
# The method should complete without raising an error
|
||||||
|
expect do
|
||||||
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
end.not_to raise_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when lock to single conversation is disabled' do
|
context 'when lock to single conversation is disabled' do
|
||||||
before do
|
before do
|
||||||
instagram_inbox.update!(lock_to_single_conversation: false)
|
instagram_inbox.update!(lock_to_single_conversation: false)
|
||||||
stub_request(:get, /graph.facebook.com/)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new conversation if existing conversation is not present' do
|
it 'creates a new conversation if existing conversation is not present' do
|
||||||
inital_count = Conversation.count
|
initial_count = Conversation.count
|
||||||
message = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
described_class.new(message, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
contact_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.count).to eq(1)
|
expect(instagram_inbox.conversations.count).to eq(1)
|
||||||
expect(Conversation.count).to eq(inital_count + 1)
|
expect(Conversation.count).to eq(initial_count + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'will not create a new conversation if last conversation is not resolved' do
|
it 'will not create a new conversation if last conversation is not resolved' do
|
||||||
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open,
|
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
|
||||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
|
contact_id: contact.id, status: :open)
|
||||||
|
|
||||||
message = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
described_class.new(message, instagram_inbox).perform
|
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
contact_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
|
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new conversation if last conversation is resolved' do
|
it 'creates a new conversation if last conversation is resolved' do
|
||||||
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
|
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
|
||||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
|
contact_id: contact.id, status: :resolved)
|
||||||
|
|
||||||
inital_count = Conversation.count
|
initial_count = Conversation.count
|
||||||
message = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
described_class.new(message, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
contact_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
|
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
|
||||||
expect(Conversation.count).to eq(inital_count + 1)
|
expect(Conversation.count).to eq(initial_count + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when lock to single conversation is enabled' do
|
context 'when lock to single conversation is enabled' do
|
||||||
before do
|
before do
|
||||||
instagram_inbox.update!(lock_to_single_conversation: true)
|
instagram_inbox.update!(lock_to_single_conversation: true)
|
||||||
stub_request(:get, /graph.facebook.com/)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new conversation if existing conversation is not present' do
|
it 'creates a new conversation if existing conversation is not present' do
|
||||||
inital_count = Conversation.count
|
initial_count = Conversation.count
|
||||||
message = dm_params[:entry][0]['messaging'][0]
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
described_class.new(message, instagram_inbox).perform
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
contact_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.count).to eq(1)
|
expect(instagram_inbox.conversations.count).to eq(1)
|
||||||
expect(Conversation.count).to eq(inital_count + 1)
|
expect(Conversation.count).to eq(initial_count + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reopens last conversation if last conversation is resolved' do
|
it 'reopens last conversation if last conversation is resolved' do
|
||||||
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
|
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
|
||||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
|
contact_id: contact.id, status: :resolved)
|
||||||
|
|
||||||
inital_count = Conversation.count
|
initial_count = Conversation.count
|
||||||
|
messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
|
|
||||||
message = dm_params[:entry][0]['messaging'][0]
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
contact_inbox
|
|
||||||
|
|
||||||
described_class.new(message, instagram_inbox).perform
|
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
contact_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
|
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
|
||||||
expect(Conversation.count).to eq(inital_count)
|
expect(Conversation.count).to eq(initial_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#fetch_story_link' do
|
||||||
|
let(:story_data) do
|
||||||
|
{
|
||||||
|
'story' => {
|
||||||
|
'mention' => {
|
||||||
|
'link' => 'https://example.com/story-link',
|
||||||
|
'id' => '18094414321535710'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'from' => {
|
||||||
|
'username' => 'instagram_user',
|
||||||
|
'id' => '2450757355263608'
|
||||||
|
},
|
||||||
|
'id' => 'story-source-id-123'
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Stub the HTTP request to Instagram API
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: story_data.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'saves story information when story mention is processed' do
|
||||||
|
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||||
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.first
|
||||||
|
|
||||||
|
expect(message.content).to include('instagram_user')
|
||||||
|
expect(message.attachments.count).to eq(1)
|
||||||
|
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
|
||||||
|
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
|
||||||
|
expect(message.content_attributes[:image_type]).to eq('story_mention')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles deleted stories' do
|
||||||
|
# Override the stub for this test to return a 404 error
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
|
||||||
|
.to_return(
|
||||||
|
status: 404,
|
||||||
|
body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||||
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.first
|
||||||
|
|
||||||
|
expect(message.content).to eq('This story is no longer available.')
|
||||||
|
expect(message.attachments.count).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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
|
||||||
@ -34,25 +34,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
|
|||||||
let(:business_hours) { false }
|
let(:business_hours) { false }
|
||||||
|
|
||||||
it 'includes correct stats for each inbox' do
|
it 'includes correct stats for each inbox' do
|
||||||
expect(report).to eq(
|
expect(report).to contain_exactly({
|
||||||
[
|
id: i1.id,
|
||||||
{
|
conversations_count: 1,
|
||||||
id: i1.id,
|
resolved_conversations_count: 0,
|
||||||
conversations_count: 1,
|
avg_resolution_time: nil,
|
||||||
resolved_conversations_count: 0,
|
avg_first_response_time: 50.0,
|
||||||
avg_resolution_time: nil,
|
avg_reply_time: 35.0
|
||||||
avg_first_response_time: 50.0,
|
}, {
|
||||||
avg_reply_time: 35.0
|
id: i2.id,
|
||||||
}, {
|
conversations_count: 1,
|
||||||
id: i2.id,
|
resolved_conversations_count: 1,
|
||||||
conversations_count: 1,
|
avg_resolution_time: 100.0,
|
||||||
resolved_conversations_count: 1,
|
avg_first_response_time: nil,
|
||||||
avg_resolution_time: 100.0,
|
avg_reply_time: nil
|
||||||
avg_first_response_time: nil,
|
})
|
||||||
avg_reply_time: nil
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -60,25 +56,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
|
|||||||
let(:business_hours) { true }
|
let(:business_hours) { true }
|
||||||
|
|
||||||
it 'uses business hours values for calculations' do
|
it 'uses business hours values for calculations' do
|
||||||
expect(report).to eq(
|
expect(report).to contain_exactly({
|
||||||
[
|
id: i1.id,
|
||||||
{
|
conversations_count: 1,
|
||||||
id: i1.id,
|
resolved_conversations_count: 0,
|
||||||
conversations_count: 1,
|
avg_resolution_time: nil,
|
||||||
resolved_conversations_count: 0,
|
avg_first_response_time: 30.0,
|
||||||
avg_resolution_time: nil,
|
avg_reply_time: 15.0
|
||||||
avg_first_response_time: 30.0,
|
}, {
|
||||||
avg_reply_time: 15.0
|
id: i2.id,
|
||||||
}, {
|
conversations_count: 1,
|
||||||
id: i2.id,
|
resolved_conversations_count: 1,
|
||||||
conversations_count: 1,
|
avg_resolution_time: 60.0,
|
||||||
resolved_conversations_count: 1,
|
avg_first_response_time: nil,
|
||||||
avg_resolution_time: 60.0,
|
avg_reply_time: nil
|
||||||
avg_first_response_time: nil,
|
})
|
||||||
avg_reply_time: nil
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -49,8 +49,24 @@ RSpec.describe Instagram::CallbacksController do
|
|||||||
expect(Channel::Instagram.last.instagram_id).to eq('12345')
|
expect(Channel::Instagram.last.instagram_id).to eq('12345')
|
||||||
expect(Inbox.last.name).to eq('test_user')
|
expect(Inbox.last.name).to eq('test_user')
|
||||||
|
|
||||||
|
expect(Inbox.last.channel.reauthorization_required?).to be false
|
||||||
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
|
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'updates existing channel with new token' do
|
||||||
|
# Create an existing channel
|
||||||
|
existing_channel = create(:channel_instagram, account: account, instagram_id: '12345', access_token: 'old_token')
|
||||||
|
create(:inbox, channel: existing_channel, account: account, name: 'old_username')
|
||||||
|
|
||||||
|
expect do
|
||||||
|
get :show, params: valid_params
|
||||||
|
end.to not_change(Channel::Instagram, :count).and not_change(Inbox, :count)
|
||||||
|
|
||||||
|
existing_channel.reload
|
||||||
|
expect(existing_channel.access_token).to eq('long_lived_test_token')
|
||||||
|
expect(existing_channel.instagram_id).to eq('12345')
|
||||||
|
expect(existing_channel.reauthorization_required?).to be false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when user denies authorization' do
|
context 'when user denies authorization' do
|
||||||
|
|||||||
@ -256,7 +256,7 @@ FactoryBot.define do
|
|||||||
},
|
},
|
||||||
'timestamp': '2021-09-08T06:34:04+0000',
|
'timestamp': '2021-09-08T06:34:04+0000',
|
||||||
'message': {
|
'message': {
|
||||||
'mid': 'message-id-1',
|
'mid': 'mention-message-id-1',
|
||||||
'attachments': [
|
'attachments': [
|
||||||
{
|
{
|
||||||
'type': 'story_mention',
|
'type': 'story_mention',
|
||||||
|
|||||||
@ -84,5 +84,29 @@ RSpec.describe SendReplyJob do
|
|||||||
expect(process_service).to receive(:perform)
|
expect(process_service).to receive(:perform)
|
||||||
described_class.perform_now(message.id)
|
described_class.perform_now(message.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do
|
||||||
|
instagram_channel = create(:channel_instagram)
|
||||||
|
message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox))
|
||||||
|
allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
|
||||||
|
expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message)
|
||||||
|
expect(process_service).to receive(:perform)
|
||||||
|
described_class.perform_now(message.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls ::Instagram::Messenger::SendOnInstagramService when its an instagram_direct_message from facebook channel' do
|
||||||
|
stub_request(:post, /graph.facebook.com/)
|
||||||
|
facebook_channel = create(:channel_facebook_page)
|
||||||
|
facebook_inbox = create(:inbox, channel: facebook_channel)
|
||||||
|
conversation = create(:conversation,
|
||||||
|
inbox: facebook_inbox,
|
||||||
|
additional_attributes: { 'type' => 'instagram_direct_message' })
|
||||||
|
message = create(:message, conversation: conversation)
|
||||||
|
|
||||||
|
allow(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
|
||||||
|
expect(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message)
|
||||||
|
expect(process_service).to receive(:perform)
|
||||||
|
described_class.perform_now(message.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
require 'webhooks/twitter'
|
|
||||||
|
|
||||||
describe Webhooks::InstagramEventsJob do
|
describe Webhooks::InstagramEventsJob do
|
||||||
subject(:instagram_webhook) { described_class }
|
subject(:instagram_webhook) { described_class }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:post, /graph.facebook.com/)
|
stub_request(:post, /graph\.facebook\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: '', headers: {})
|
||||||
end
|
end
|
||||||
@ -14,39 +13,52 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
let(:return_object) do
|
let(:return_object) do
|
||||||
{ name: 'Jane',
|
{ name: 'Jane',
|
||||||
id: 'Sender-id-1',
|
id: 'Sender-id-1',
|
||||||
account_id: instagram_inbox.account_id,
|
account_id: instagram_messenger_inbox.account_id,
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png',
|
profile_pic: 'https://chatwoot-assets.local/sample.png',
|
||||||
username: 'some_user_name' }
|
username: 'some_user_name' }
|
||||||
end
|
end
|
||||||
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||||
|
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
|
||||||
|
|
||||||
|
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||||
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
|
|
||||||
let!(:standby_params) { build(:instagram_message_standby_event).with_indifferent_access }
|
# Combined message events into one helper
|
||||||
let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access }
|
let(:message_events) do
|
||||||
let!(:unsend_event) { build(:instagram_message_unsend_event).with_indifferent_access }
|
{
|
||||||
let!(:attachment_params) { build(:instagram_message_attachment_event).with_indifferent_access }
|
dm: build(:instagram_message_create_event).with_indifferent_access,
|
||||||
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
|
standby: build(:instagram_message_standby_event).with_indifferent_access,
|
||||||
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
|
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
|
||||||
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access }
|
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
|
||||||
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access }
|
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
|
||||||
let(:fb_object) { double }
|
story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
|
||||||
|
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
|
||||||
|
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
context 'with direct_message params' do
|
context 'when handling messaging events for Instagram via Facebook page' do
|
||||||
|
let(:fb_object) { double }
|
||||||
|
|
||||||
|
before do
|
||||||
|
instagram_inbox.destroy
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates incoming message in the instagram inbox' do
|
it 'creates incoming message in the instagram inbox' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
return_object.with_indifferent_access
|
return_object.with_indifferent_access
|
||||||
)
|
)
|
||||||
instagram_webhook.perform_now(dm_params[:entry])
|
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
expect(instagram_inbox.contacts.count).to be 1
|
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||||
expect(instagram_inbox.conversations.count).to be 1
|
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
|
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates standby message in the instagram inbox' do
|
it 'creates standby message in the instagram inbox' do
|
||||||
@ -54,53 +66,40 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
allow(fb_object).to receive(:get_object).and_return(
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
return_object.with_indifferent_access
|
return_object.with_indifferent_access
|
||||||
)
|
)
|
||||||
instagram_webhook.perform_now(standby_params[:entry])
|
instagram_webhook.perform_now(message_events[:standby][:entry])
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
expect(instagram_inbox.contacts.count).to be 1
|
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||||
expect(instagram_inbox.conversations.count).to be 1
|
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
|
||||||
message = instagram_inbox.messages.last
|
message = instagram_messenger_inbox.messages.last
|
||||||
expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.')
|
expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates test text message in the instagram inbox' do
|
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
return_object.with_indifferent_access
|
|
||||||
)
|
|
||||||
instagram_webhook.perform_now(test_params[:entry])
|
|
||||||
|
|
||||||
instagram_inbox.reload
|
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
|
||||||
expect(instagram_inbox.messages.last.content).to eq('random_text')
|
|
||||||
expect(instagram_inbox.messages.last.source_id).to eq('random_mid')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handle instagram unsend message event' do
|
it 'handle instagram unsend message event' do
|
||||||
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete')
|
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
{
|
{
|
||||||
name: 'Jane',
|
name: 'Jane',
|
||||||
id: 'Sender-id-1',
|
id: 'Sender-id-1',
|
||||||
account_id: instagram_inbox.account_id,
|
account_id: instagram_messenger_inbox.account_id,
|
||||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
)
|
)
|
||||||
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
|
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
|
||||||
|
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
|
||||||
instagram_webhook.perform_now(unsend_event[:entry])
|
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||||
|
|
||||||
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
|
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
|
||||||
expect(instagram_inbox.messages.last.deleted).to be true
|
expect(instagram_messenger_inbox.messages.last.deleted).to be true
|
||||||
expect(instagram_inbox.messages.last.attachments.count).to be 0
|
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 0
|
||||||
expect(instagram_inbox.messages.last.reload.deleted).to be true
|
expect(instagram_messenger_inbox.messages.last.reload.deleted).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates incoming message with attachments in the instagram inbox' do
|
it 'creates incoming message with attachments in the instagram inbox' do
|
||||||
@ -108,13 +107,13 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
allow(fb_object).to receive(:get_object).and_return(
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
return_object.with_indifferent_access
|
return_object.with_indifferent_access
|
||||||
)
|
)
|
||||||
instagram_webhook.perform_now(attachment_params[:entry])
|
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
expect(instagram_inbox.contacts.count).to be 1
|
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates incoming message with attachments in the instagram inbox for story mention' do
|
it 'creates incoming message with attachments in the instagram inbox for story mention' do
|
||||||
@ -134,22 +133,145 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
id: 'instagram-message-id-1234' }.with_indifferent_access
|
id: 'instagram-message-id-1234' }.with_indifferent_access
|
||||||
)
|
)
|
||||||
|
|
||||||
instagram_webhook.perform_now(story_mention_params[:entry])
|
instagram_webhook.perform_now(message_events[:story_mention][:entry])
|
||||||
|
|
||||||
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||||
|
|
||||||
|
attachment = instagram_messenger_inbox.messages.last.attachments.last
|
||||||
|
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create contact or messages when Facebook API call fails' do
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
|
||||||
|
|
||||||
|
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||||
|
|
||||||
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_messenger_inbox.contacts.count).to be 0
|
||||||
|
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
|
||||||
|
expect(instagram_messenger_inbox.messages.count).to be 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handle messaging_seen callback' do
|
||||||
|
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||||
|
channel: instagram_messenger_inbox.channel).and_call_original
|
||||||
|
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles unsupported message' do
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
|
return_object.with_indifferent_access
|
||||||
|
)
|
||||||
|
|
||||||
|
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||||
|
instagram_messenger_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||||
|
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when handling messaging events for Instagram via Instagram login' do
|
||||||
|
before do
|
||||||
|
instagram_channel.update!(access_token: 'valid_instagram_token')
|
||||||
|
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
name: 'Jane',
|
||||||
|
username: 'some_user_name',
|
||||||
|
profile_pic: 'https://chatwoot-assets.local/sample.png',
|
||||||
|
id: 'Sender-id-1',
|
||||||
|
follower_count: 100,
|
||||||
|
is_user_follow_business: true,
|
||||||
|
is_business_follow_user: true,
|
||||||
|
is_verified_user: false
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates incoming message with correct contact info in the instagram direct inbox' do
|
||||||
|
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||||
|
instagram_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_inbox.contacts.count).to eq 1
|
||||||
|
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||||
|
expect(instagram_inbox.conversations.count).to eq 1
|
||||||
|
expect(instagram_inbox.messages.count).to eq 1
|
||||||
|
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets correct instagram attributes on contact' do
|
||||||
|
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||||
|
instagram_inbox.reload
|
||||||
|
|
||||||
|
contact = instagram_inbox.contacts.last
|
||||||
|
|
||||||
|
expect(contact.additional_attributes['social_instagram_follower_count']).to eq 100
|
||||||
|
expect(contact.additional_attributes['social_instagram_is_user_follow_business']).to be true
|
||||||
|
expect(contact.additional_attributes['social_instagram_is_business_follow_user']).to be true
|
||||||
|
expect(contact.additional_attributes['social_instagram_is_verified_user']).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handle instagram unsend message event' do
|
||||||
|
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
|
||||||
|
|
||||||
|
# Create attachment correctly with account association
|
||||||
|
message.attachments.create!(
|
||||||
|
file_type: :image,
|
||||||
|
external_url: 'https://www.example.com/test.jpeg',
|
||||||
|
account_id: instagram_inbox.account_id
|
||||||
|
)
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_inbox.reload
|
||||||
|
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
|
||||||
|
|
||||||
attachment = instagram_inbox.messages.last.attachments.last
|
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||||
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
|
|
||||||
|
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
|
||||||
|
expect(instagram_inbox.messages.last.deleted).to be true
|
||||||
|
expect(instagram_inbox.messages.last.attachments.count).to be 0
|
||||||
|
expect(instagram_inbox.messages.last.reload.deleted).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates does not create contact or messages' do
|
it 'creates incoming message with attachments in the instagram direct inbox' do
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||||
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
|
|
||||||
|
|
||||||
instagram_webhook.perform_now(story_mention_echo_params[:entry])
|
instagram_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_inbox.contacts.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles unsupported message' do
|
||||||
|
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||||
|
instagram_inbox.reload
|
||||||
|
|
||||||
|
expect(instagram_inbox.contacts.count).to be 1
|
||||||
|
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||||
|
expect(instagram_inbox.conversations.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create contact or messages when Instagram API call fails' do
|
||||||
|
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||||
|
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
|
||||||
|
|
||||||
|
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||||
|
|
||||||
instagram_inbox.reload
|
instagram_inbox.reload
|
||||||
|
|
||||||
@ -159,24 +281,9 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'handle messaging_seen callback' do
|
it 'handle messaging_seen callback' do
|
||||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original
|
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||||
instagram_webhook.perform_now(messaging_seen_event[:entry])
|
channel: instagram_inbox.channel).and_call_original
|
||||||
end
|
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||||
|
|
||||||
it 'handles unsupported message' do
|
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
return_object.with_indifferent_access
|
|
||||||
)
|
|
||||||
|
|
||||||
instagram_webhook.perform_now(unsupported_message_event[:entry])
|
|
||||||
instagram_inbox.reload
|
|
||||||
|
|
||||||
expect(instagram_inbox.contacts.count).to be 1
|
|
||||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
|
||||||
expect(instagram_inbox.conversations.count).to be 1
|
|
||||||
expect(instagram_inbox.messages.count).to be 1
|
|
||||||
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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
|
||||||
@ -30,7 +30,7 @@ describe Instagram::ReadStatusService do
|
|||||||
mid: message.source_id
|
mid: message.source_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described_class.new(params: params).perform
|
described_class.new(params: params, channel: instagram_channel).perform
|
||||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at)
|
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ describe Instagram::ReadStatusService do
|
|||||||
mid: 'random-message-id'
|
mid: 'random-message-id'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
described_class.new(params: params).perform
|
described_class.new(params: params, channel: instagram_channel).perform
|
||||||
expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later)
|
expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -3,14 +3,10 @@ require 'rails_helper'
|
|||||||
describe Instagram::SendOnInstagramService do
|
describe Instagram::SendOnInstagramService do
|
||||||
subject(:send_reply_service) { described_class.new(message: message) }
|
subject(:send_reply_service) { described_class.new(message: message) }
|
||||||
|
|
||||||
before do
|
|
||||||
stub_request(:post, /graph\.facebook\.com/)
|
|
||||||
create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'instagram-message-id-123') }
|
||||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||||
|
|
||||||
let!(:contact) { create(:contact, account: account) }
|
let!(:contact) { create(:contact, account: account) }
|
||||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
|
||||||
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
|
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
|
||||||
@ -19,8 +15,8 @@ describe Instagram::SendOnInstagramService do
|
|||||||
instance_double(
|
instance_double(
|
||||||
HTTParty::Response,
|
HTTParty::Response,
|
||||||
:success? => true,
|
:success? => true,
|
||||||
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
|
:body => { message_id: 'random_message_id' }.to_json,
|
||||||
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
|
:parsed_response => { 'message_id' => 'random_message_id' }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -56,24 +52,24 @@ describe Instagram::SendOnInstagramService do
|
|||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
context 'with reply' do
|
context 'with reply' do
|
||||||
before do
|
before do
|
||||||
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
|
|
||||||
allow(HTTParty).to receive(:post).and_return(mock_response)
|
allow(HTTParty).to receive(:post).and_return(mock_response)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'without message_tag HUMAN_AGENT' do
|
context 'without message_tag HUMAN_AGENT' do
|
||||||
before do
|
before do
|
||||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
|
InstallationConfig.where(name: 'ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'if message is sent from chatwoot and is outgoing' do
|
it 'if message is sent from chatwoot and is outgoing' do
|
||||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||||
|
|
||||||
response = described_class.new(message: message).perform
|
response = described_class.new(message: message).perform
|
||||||
expect(response['message_id']).to eq('anyrandommessageid1234567890')
|
expect(response['message_id']).to eq('random_message_id')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
|
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
|
||||||
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account,
|
||||||
|
conversation: conversation)
|
||||||
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||||
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||||
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||||
@ -82,12 +78,12 @@ describe Instagram::SendOnInstagramService do
|
|||||||
|
|
||||||
service = described_class.new(message: message)
|
service = described_class.new(message: message)
|
||||||
|
|
||||||
# Stub the send_to_facebook_page method on the service instance
|
# Stub the send_message method on the service instance
|
||||||
allow(service).to receive(:send_to_facebook_page)
|
allow(service).to receive(:send_message)
|
||||||
service.perform
|
service.perform
|
||||||
|
|
||||||
# Now you can set expectations on the stubbed method for each attachment
|
# Now you can set expectations on the stubbed method for each attachment
|
||||||
expect(service).to have_received(:send_to_facebook_page).exactly(:twice)
|
expect(service).to have_received(:send_message).exactly(:twice)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'if message with attachment is sent from chatwoot and is outgoing' do
|
it 'if message with attachment is sent from chatwoot and is outgoing' do
|
||||||
@ -97,7 +93,7 @@ describe Instagram::SendOnInstagramService do
|
|||||||
message.save!
|
message.save!
|
||||||
response = described_class.new(message: message).perform
|
response = described_class.new(message: message).perform
|
||||||
|
|
||||||
expect(response['message_id']).to eq('anyrandommessageid1234567890')
|
expect(response['message_id']).to eq('random_message_id')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'if message sent from chatwoot is failed' do
|
it 'if message sent from chatwoot is failed' do
|
||||||
@ -130,7 +126,7 @@ describe Instagram::SendOnInstagramService do
|
|||||||
}
|
}
|
||||||
).and_return(
|
).and_return(
|
||||||
{
|
{
|
||||||
'message_id': 'anyrandommessageid1234567890'
|
'message_id': 'random_message_id'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -138,39 +134,54 @@ describe Instagram::SendOnInstagramService do
|
|||||||
expect(HTTParty).to have_received(:post)
|
expect(HTTParty).to have_received(:post)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
context 'when handling errors' do
|
context 'when handling errors' do
|
||||||
before do
|
it 'handles HTTP errors' do
|
||||||
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
|
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||||
end
|
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||||
|
|
||||||
it 'handles HTTP errors' do
|
described_class.new(message: message).perform
|
||||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
|
||||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
|
||||||
|
|
||||||
described_class.new(message: message).perform
|
expect(message.reload.status).to eq('failed')
|
||||||
|
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
||||||
|
end
|
||||||
|
|
||||||
expect(message.reload.status).to eq('failed')
|
it 'handles response errors' do
|
||||||
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles response errors' do
|
error_response = instance_double(
|
||||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
HTTParty::Response,
|
||||||
|
success?: true,
|
||||||
|
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
|
||||||
|
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
|
||||||
|
)
|
||||||
|
|
||||||
error_response = instance_double(
|
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||||
HTTParty::Response,
|
|
||||||
success?: true,
|
|
||||||
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
|
|
||||||
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
|
|
||||||
)
|
|
||||||
|
|
||||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
described_class.new(message: message).perform
|
expect(message.reload.status).to eq('failed')
|
||||||
|
expect(message.reload.external_error).to eq('100 - Invalid message format')
|
||||||
|
end
|
||||||
|
|
||||||
expect(message.reload.status).to eq('failed')
|
it 'handles reauthorization errors if access token is expired' do
|
||||||
expect(message.reload.external_error).to eq('100 - Invalid message format')
|
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||||
|
|
||||||
|
error_response = instance_double(
|
||||||
|
HTTParty::Response,
|
||||||
|
success?: false,
|
||||||
|
body: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }.to_json,
|
||||||
|
parsed_response: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
|
expect(message.reload.status).to eq('failed')
|
||||||
|
expect(message.reload.external_error).to eq('190 - Access token has expired')
|
||||||
|
expect(instagram_channel.reload).to be_reauthorization_required
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -315,6 +315,21 @@ export const colors = {
|
|||||||
12: 'rgb(var(--iris-12) / <alpha-value>)',
|
12: 'rgb(var(--iris-12) / <alpha-value>)',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
blue: {
|
||||||
|
1: 'rgb(var(--blue-1) / <alpha-value>)',
|
||||||
|
2: 'rgb(var(--blue-2) / <alpha-value>)',
|
||||||
|
3: 'rgb(var(--blue-3) / <alpha-value>)',
|
||||||
|
4: 'rgb(var(--blue-4) / <alpha-value>)',
|
||||||
|
5: 'rgb(var(--blue-5) / <alpha-value>)',
|
||||||
|
6: 'rgb(var(--blue-6) / <alpha-value>)',
|
||||||
|
7: 'rgb(var(--blue-7) / <alpha-value>)',
|
||||||
|
8: 'rgb(var(--blue-8) / <alpha-value>)',
|
||||||
|
9: 'rgb(var(--blue-9) / <alpha-value>)',
|
||||||
|
10: 'rgb(var(--blue-10) / <alpha-value>)',
|
||||||
|
11: 'rgb(var(--blue-11) / <alpha-value>)',
|
||||||
|
12: 'rgb(var(--blue-12) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
|
||||||
ruby: {
|
ruby: {
|
||||||
1: 'rgb(var(--ruby-1) / <alpha-value>)',
|
1: 'rgb(var(--ruby-1) / <alpha-value>)',
|
||||||
2: 'rgb(var(--ruby-2) / <alpha-value>)',
|
2: 'rgb(var(--ruby-2) / <alpha-value>)',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user