chore: merge upstream (#25)

This commit is contained in:
Gabriel Jablonski 2025-04-09 19:37:55 -03:00 committed by GitHub
commit d0eb84dbed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2514 additions and 1059 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,179 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if @outgoing_echo
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return 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

View File

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

View File

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

View File

@ -68,20 +68,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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,
}; };
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'],

View File

@ -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);
});
});
});
});

View File

@ -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.',
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,69 @@
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
attr_reader :messaging
def initialize(messaging, channel)
@messaging = messaging
super(channel)
end
def perform
connected_instagram_id, contact_id = instagram_and_contact_ids
inbox_channel(connected_instagram_id)
return if @inbox.blank?
if @inbox.channel.reauthorization_required?
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
return
end
return unsend_message if message_is_deleted?
ensure_contact(contact_id) if contacts_first_message?(contact_id)
create_message
end
private
def instagram_and_contact_ids
if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
end
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
end
def message_is_deleted?
@messaging[:message][:is_deleted].present?
end
# if contact was present before find out contact_inbox to create message
def contacts_first_message?(ig_scope_id)
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
end
def unsend_message
message_to_delete = @inbox.messages.find_by(
source_id: @messaging[:message][:mid]
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
end
# Methods to be implemented by subclasses
def ensure_contact(contact_id)
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
end
def create_message
raise NotImplementedError, "#{self.class} must implement #create_message"
end
end

View File

@ -0,0 +1,95 @@
class Instagram::BaseSendService < Base::SendOnChannelService
pattr_initialize [:message!]
private
delegate :additional_attributes, to: :contact
def perform_reply
send_attachments if message.attachments.present?
send_content if message.content.present?
rescue StandardError => e
handle_error(e)
end
def send_attachments
message.attachments.each do |attachment|
send_message(attachment_message_params(attachment))
end
end
def send_content
send_message(message_params)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
end
def process_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
message.update!(status: :failed, external_error: external_error)
nil
end
end
def external_error(response)
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid. This may be due to a password change,
# removal of the connected app from Instagram account settings, or other reasons.
channel.authorization_error! if error_code == 190
"#{error_code} - #{error_message}"
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
# Methods to be implemented by child classes
def send_message(message_content)
raise NotImplementedError, 'Subclasses must implement send_message'
end
def merge_human_agent_tag(params)
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
end
end

View File

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

View File

@ -0,0 +1,37 @@
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
private
def ensure_contact(ig_scope_id)
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def fetch_instagram_user(ig_scope_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
handle_authentication_error(e)
{}
rescue StandardError, Koala::Facebook::ClientError => e
handle_client_error(e)
{}
end
def handle_authentication_error(error)
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def handle_client_error(error)
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def create_message
return unless @contact_inbox
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
end

View File

@ -0,0 +1,40 @@
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
private
def channel_class
Channel::FacebookPage
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_message(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
process_response(response, message_content)
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>)',