Compare commits

..
115 changed files with 1284 additions and 3906 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using
# # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
services:
postgres:
image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier)
return if identifier.blank?
@@ -1,178 +0,0 @@
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.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,200 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end
private
def get_story_object_from_source_id(source_id)
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
def attachments
@messaging[:message][:attachments] || {}
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_type
@outgoing_echo ? :outgoing : :incoming
end
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
def message_identifier
message[:mid]
end
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save!
end
# This is a placeholder method to be overridden by child classes
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
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline]
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
@@ -1,5 +1,6 @@
module InstagramConcern
extend ActiveSupport::Concern
include HTTParty
def instagram_client
::OAuth2::Client.new(
@@ -10,8 +10,20 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private
def sign_in_user
was_confirmed = @resource.confirmed? if confirmable_enabled?
@resource.skip_confirmation! if confirmable_enabled?
# rubocop:disable Rails/SkipsModelValidations
# Set random password if user was not previously confirmed
# This is to prevent an malicious user from acting in
# case they created an account with this email before
if confirmable_enabled? && !was_confirmed
# Generate proper bcrypt hash for the password instead of storing raw password
encrypted_password = BCrypt::Password.create(SecureRandom.hex(32))
@resource.update_column(:encrypted_password, encrypted_password)
end
# rubocop:enable Rails/SkipsModelValidations
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
# that will log them in
@@ -55,7 +67,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def validate_business_account?
# return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
auth_hash['info']['email'].exclude?('@gmail.com')
end
def create_account_for_user
@@ -29,19 +29,6 @@
--iris-11: 87 83 198;
--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-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -144,19 +131,6 @@
--iris-11: 158 177 255;
--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-2: 30 21 23;
--ruby-3: 58 20 30;
@@ -51,7 +51,6 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
@@ -31,6 +31,10 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.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'),
value: 'company_name',
@@ -33,8 +33,6 @@ const insertIntoRichEditor = computed(() => {
);
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -55,17 +53,9 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
@@ -12,7 +12,6 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
};
const providerIconMap = {
@@ -19,17 +19,10 @@ const {
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
} = useInbox();
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const { status, isPrivate, createdAt, sourceId, messageType } =
useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -37,11 +30,6 @@ const readableTime = computed(() =>
const showStatusIndicator = computed(() => {
if (isPrivate.value) return false;
// Don't show status for failed messages, we already show error message
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
@@ -59,8 +47,7 @@ const isSent = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAInstagramChannel.value
isATelegramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -99,8 +86,7 @@ const isRead = computed(() => {
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAInstagramChannel.value
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -116,6 +102,7 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS;
});
@@ -75,7 +75,7 @@ const hasQuotedMessage = computed(() => {
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-slate-12',
pdf: 'text-n-ruby-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
@@ -218,14 +218,14 @@ const emitDateRange = () => {
/>
<div
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 bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
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"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
@@ -251,12 +251,12 @@ const emitDateRange = () => {
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-n-strong">
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-n-strong'
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
@@ -1,8 +1,6 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
calendarType: {
type: String,
@@ -40,38 +38,42 @@ const onClickSetView = (type, mode) => {
<template>
<div class="flex items-start justify-between w-full h-9">
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@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">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
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"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
/>
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>
@@ -65,7 +65,7 @@ const validateDate = () => {
<input
v-model="localDateValue"
type="text"
class="!text-sm !mb-0 disabled:!outline-n-strong"
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"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
@@ -18,7 +18,7 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
@@ -26,11 +26,11 @@ const setDateRange = range => {
<button
v-for="range in dateRanges"
:key="range.label"
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="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="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
@@ -1,6 +1,4 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
@@ -13,19 +11,18 @@ const onClickApply = () => {
</script>
<template>
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
<NextButton
slate
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
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"
@click="onClickClear"
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
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"
@click="onClickApply"
/>
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>
@@ -71,12 +71,10 @@ const selectMonth = index => {
<button
v-for="(month, index) in months"
:key="index"
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="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="{
'bg-n-brand text-white hover:bg-n-blue-10':
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click="selectMonth(index)"
>
@@ -107,16 +107,17 @@ const isNextDayInRange = day => {
};
const dayClasses = day => ({
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'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),
'bg-n-brand text-white':
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5':
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -163,7 +164,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
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"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
@@ -72,10 +72,10 @@ const selectYear = year => {
<button
v-for="year in years"
:key="year"
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="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="{
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
@@ -48,7 +48,7 @@ const openDatePicker = () => {
<template>
<button
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"
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"
@click="openDatePicker"
>
<fluent-icon
@@ -32,10 +32,6 @@ export default {
return this.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return this.enabledFeatures.channel_instagram;
}
return [
'website',
'twilio',
@@ -44,7 +40,6 @@ export default {
'sms',
'telegram',
'line',
'instagram',
].includes(key);
},
},
@@ -220,7 +220,6 @@ const plugins = computed(() => {
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}),
createSuggestionPlugin({
trigger: '/',
@@ -775,24 +774,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 {
@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;
}
}
@apply dark:text-slate-25 text-slate-800;
}
}
@@ -328,24 +328,11 @@ export default {
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@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;
}
}
}
z-index: var(--z-index-highest);
background: var(--white);
box-shadow: var(--shadow-large);
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
min-width: 25rem;
}
</style>
@@ -36,7 +36,6 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -210,17 +209,6 @@ export default {
return contactLastSeenAt;
},
// Check there is a facebook inbox with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return this.inbox.channel_type === INBOX_TYPES.FB && instagramInbox;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -230,14 +218,8 @@ export default {
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
return agentReplyTimeWindowMessage;
}
return '';
}
@@ -515,12 +497,6 @@ export default {
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
@@ -261,8 +261,7 @@ export default {
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel ||
this.isAInstagramChannel
this.isALineChannel
);
},
replyButtonLabel() {
@@ -1077,7 +1076,7 @@ export default {
v-if="showSelfAssignBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign"
class="banner--self-assign mx-2 mb-2 rounded-lg"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@@ -1136,7 +1135,7 @@ export default {
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="input rounded-none"
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
@@ -69,6 +69,10 @@ const onAgentSelect = index => {
v-if="items.length"
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="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
>
<li
v-for="(agent, index) in items"
@@ -121,10 +121,6 @@ export const useInbox = () => {
);
});
const isAInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
return {
inbox,
isAFacebookInbox,
@@ -141,6 +137,5 @@ export const useInbox = () => {
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
};
};
@@ -7,12 +7,8 @@ import { formatTime } from '@chatwoot/utils';
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics.
*/
export function useReportMetrics(
accountSummaryKey = 'getAccountSummary',
summarFetchingKey = 'getAccountSummaryFetchingStatus'
) {
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
const accountSummary = useMapGetter(accountSummaryKey);
const fetchingStatus = useMapGetter(summarFetchingKey);
/**
* Calculates the trend percentage for a given metric.
@@ -57,6 +53,5 @@ export function useReportMetrics(
calculateTrend,
isAverageMetricType,
displayMetric,
fetchingStatus,
};
}
@@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
// If the sidebar order is not set, use the default order.
if (!itemsOrder) {
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
}
// Create a copy of itemsOrder to avoid mutating the original store object.
const itemsOrderCopy = [...itemsOrder];
-1
View File
@@ -34,7 +34,6 @@ export const FEATURE_FLAGS = {
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
};
@@ -16,15 +16,6 @@ export const getUserPermissions = (user, accountId) => {
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 =>
route.meta && route.meta.permissions;
@@ -32,12 +32,10 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -48,12 +48,9 @@
},
"INSTAGRAM": {
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
@@ -326,7 +326,6 @@
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
@@ -35,8 +35,6 @@ export default {
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
// TODO: Add Instagram to the channel list after the feature is ready to use.
// { key: 'instagram', name: 'Instagram' },
];
},
...mapGetters({
@@ -64,7 +62,7 @@ export default {
<template>
<div
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"
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"
>
<PageHeader
class="max-w-4xl"
@@ -1,13 +1,11 @@
<script>
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
EmptyState,
NextButton,
DuplicateInboxBanner,
},
computed: {
currentInbox() {
@@ -18,20 +16,6 @@ export default {
isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms';
},
// Check if a facebook inbox that has the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.currentInbox.instagram_id;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
instagramInbox
);
},
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
@@ -88,12 +72,8 @@ export default {
<template>
<div
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"
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"
>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
/>
<EmptyState
:title="$t('INBOX_MGMT.FINISH.TITLE')"
:message="message"
@@ -8,7 +8,6 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
@@ -21,7 +20,6 @@ import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
@@ -40,7 +38,6 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
},
mixins: [inboxMixin],
setup() {
@@ -210,16 +207,6 @@ export default {
instagramUnauthorized() {
return this.isAInstagramChannel && this.inbox.reauthorization_required;
},
// Check if a facebook inbox that has the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return this.inbox.channel_type === INBOX_TYPES.FB && instagramInbox;
},
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
},
@@ -406,11 +393,6 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
@@ -67,7 +67,7 @@ export default {
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="flex flex-col items-center justify-start h-full text-center">
<div class="flex flex-col items-center justify-center h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
<p
@@ -77,20 +77,23 @@ export default {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center h-full text-center"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
<button
class="flex items-center justify-center px-8 py-3.5 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"
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"
:disabled="isRequestingAuthorization"
@click="requestAuthorization()"
>
<span class="i-ri-instagram-line size-5" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
/>
</svg>
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
@@ -117,6 +120,9 @@ export default {
</svg>
</span>
</button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div>
</div>
</div>
@@ -1,16 +0,0 @@
<script setup>
import Banner from 'dashboard/components-next/banner/Banner.vue';
defineProps({
content: {
type: String,
default: '',
},
});
</script>
<template>
<Banner color="ruby">
{{ content }}
</Banner>
</template>
@@ -189,4 +189,10 @@ export default {
.formkit-actions {
@apply hidden;
}
@media (prefers-color-scheme: dark) {
.pre-chat-header-message .link {
@apply text-woot-500 underline;
}
}
</style>
@@ -98,7 +98,6 @@ export default {
<BotMetrics :filters="requestPayload" />
<ReportContainer
account-summary-key="getBotSummary"
summary-fetching-key="getBotSummaryFetchingStatus"
:group-by="groupBy"
:report-keys="reportKeys"
/>
@@ -19,10 +19,6 @@ export default {
type: String,
default: 'getAccountSummary',
},
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
reportKeys: {
type: Object,
default: () => ({
@@ -152,11 +148,7 @@ export default {
:key="metric.KEY"
class="p-4 mb-3 rounded-md"
>
<ChartStats
:metric="metric"
:account-summary-key="accountSummaryKey"
:summary-fetching-key="summaryFetchingKey"
/>
<ChartStats :metric="metric" :account-summary-key="accountSummaryKey" />
<div class="mt-4 h-72">
<woot-loading-state
v-if="accountReport.isFetching[metric.KEY]"
@@ -1,8 +1,5 @@
<script setup>
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { STATUS } from 'dashboard/store/constants';
import { useI18n } from 'vue-i18n';
const props = defineProps({
metric: {
@@ -13,16 +10,11 @@ const props = defineProps({
type: String,
default: 'getAccountSummary',
},
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
});
const { t } = useI18n();
const { calculateTrend, displayMetric, isAverageMetricType, fetchingStatus } =
useReportMetrics(props.accountSummaryKey, props.summaryFetchingKey);
const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics(
props.accountSummaryKey
);
const trendColor = (value, key) => {
if (isAverageMetricType(key)) {
@@ -42,25 +34,10 @@ const trendColor = (value, key) => {
{{ metric.NAME }}
</span>
<div class="flex items-end text-n-slate-12">
<div v-if="fetchingStatus === STATUS.FETCHING">
<Spinner />
</div>
<div
v-else-if="fetchingStatus === STATUS.FAILED"
class="text-n-ruby-10 text-sm"
>
{{ t('REPORT.SUMMARY_FETCHING_FAILED') }}
</div>
<div
v-else-if="fetchingStatus === STATUS.FINISHED"
class="text-xl font-medium"
>
<div class="text-xl font-medium">
{{ displayMetric(metric.KEY) }}
</div>
<div
v-if="metric.trend && fetchingStatus === STATUS.FINISHED"
class="text-xs ml-4 flex items-center mb-0.5"
>
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
<div
v-if="metric.trend < 0"
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1"
@@ -1,5 +0,0 @@
export const STATUS = {
FAILED: 'failed',
FETCHING: 'fetching',
FINISHED: 'finished',
};
@@ -1,11 +1,7 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
import { applyPageFilters, sortComparator } from './helpers';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import { matchesFilters } from './helpers/filterHelpers';
import {
getUserPermissions,
getUserRole,
} from '../../../helper/permissionsHelper';
import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({
@@ -81,24 +77,10 @@ const getters = {
return isUnAssigned && shouldFilter;
});
},
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);
getAllStatusChats: _state => activeFilters => {
return _state.allConversations.filter(conversation => {
const shouldFilter = applyPageFilters(conversation, activeFilters);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return shouldFilter && allowedForRole;
return shouldFilter;
});
},
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
@@ -62,51 +62,6 @@ export const applyPageFilters = (conversation, filters) => {
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 = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
@@ -1,276 +0,0 @@
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);
});
});
});
});
@@ -122,13 +122,6 @@ export const getters = {
item => item.channel_type !== INBOX_TYPES.EMAIL
);
},
getInstagramInboxByInstagramId: $state => instagramId => {
return $state.records.find(
item =>
item.instagram_id === instagramId &&
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
};
const sendAnalyticsEvent = channelType => {
@@ -1,6 +1,5 @@
/* eslint no-console: 0 */
import * as types from '../mutation-types';
import { STATUS } from '../constants';
import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
@@ -10,8 +9,6 @@ import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
accountSummaryFetchingStatus: STATUS.FINISHED,
botSummaryFetchingStatus: STATUS.FINISHED,
accountReport: {
isFetching: {
conversations_count: false,
@@ -77,12 +74,6 @@ const getters = {
getBotSummary(_state) {
return _state.botSummary;
},
getAccountSummaryFetchingStatus(_state) {
return _state.accountSummaryFetchingStatus;
},
getBotSummaryFetchingStatus(_state) {
return _state.botSummaryFetchingStatus;
},
getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric;
},
@@ -131,7 +122,6 @@ export const actions = {
});
},
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
reportObj.from,
reportObj.to,
@@ -142,14 +132,12 @@ export const actions = {
)
.then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED);
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchBotSummary({ commit }, reportObj) {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getBotSummary({
from: reportObj.from,
to: reportObj.to,
@@ -158,10 +146,9 @@ export const actions = {
})
.then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, params = {}) {
@@ -290,12 +277,6 @@ const mutations = {
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
[types.default.SET_BOT_SUMMARY_STATUS](_state, status) {
_state.botSummaryFetchingStatus = status;
},
[types.default.SET_ACCOUNT_SUMMARY_STATUS](_state, status) {
_state.accountSummaryFetchingStatus = status;
},
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
},
@@ -9,7 +9,6 @@ export default [
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
},
{
id: 2,
@@ -63,12 +62,4 @@ export default [
channel_type: 'Channel::Sms',
provider: 'default',
},
{
id: 7,
channel_id: 7,
name: 'Test Instagram 1',
channel_type: 'Channel::Instagram',
instagram_id: 123456789,
provider: 'default',
},
];
@@ -26,7 +26,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
});
it('getInbox', () => {
@@ -43,7 +43,6 @@ describe('#getters', () => {
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
});
});
@@ -65,16 +64,4 @@ describe('#getters', () => {
isDeleting: false,
});
});
it('getInstagramInboxByInstagramId', () => {
const state = { records: inboxList };
expect(getters.getInstagramInboxByInstagramId(state)(123456789)).toEqual({
id: 7,
channel_id: 7,
name: 'Test Instagram 1',
channel_type: 'Channel::Instagram',
instagram_id: 123456789,
provider: 'default',
});
});
});
@@ -1,122 +1,14 @@
import axios from 'axios';
import { actions } from '../../reports';
import * as types from '../../../mutation-types';
import { STATUS } from '../../../constants';
import * as DownloadHelper from 'dashboard/helper/downloadHelper';
import { flushPromises } from '@vue/test-utils';
global.open = vi.fn();
global.axios = axios;
global.URL.createObjectURL = vi.fn();
vi.mock('axios');
vi.spyOn(DownloadHelper, 'downloadCsvFile');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetchAccountSummary', () => {
it('sends correct actions if API is success', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
type: 'account',
id: 1,
groupBy: 'day',
businessHours: true,
};
const summaryData = {
conversations_count: 10,
incoming_messages_count: 20,
outgoing_messages_count: 15,
avg_first_response_time: 30,
avg_resolution_time: 60,
resolutions_count: 5,
bot_resolutions_count: 2,
bot_handoffs_count: 1,
reply_time: 25,
};
axios.get.mockResolvedValue({ data: summaryData });
actions.fetchAccountSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_ACCOUNT_SUMMARY, summaryData],
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED],
]);
});
it('sends correct actions if API fails', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
};
axios.get.mockRejectedValue(new Error('API Error'));
actions.fetchAccountSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED],
]);
});
});
describe('#fetchBotSummary', () => {
it('sends correct actions if API is success', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
groupBy: 'day',
businessHours: true,
};
const summaryData = {
bot_resolutions_count: 10,
bot_handoffs_count: 5,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 4,
},
};
axios.get.mockResolvedValue({ data: summaryData });
actions.fetchBotSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_BOT_SUMMARY, summaryData],
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED],
]);
});
it('sends correct actions if API fails', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
};
const error = new Error('API error');
axios.get.mockRejectedValueOnce(error);
actions.fetchBotSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED],
]);
});
});
describe('#downloadAgentReports', () => {
it('open CSV download prompt if API is success', async () => {
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
@@ -128,9 +20,7 @@ describe('#actions', () => {
to: 1630504922510,
fileName: 'agent-report-01-09-2021.csv',
};
actions.downloadAgentReports(1, param);
await flushPromises();
await actions.downloadAgentReports(1, param);
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -149,9 +39,7 @@ describe('#actions', () => {
type: 'label',
fileName: 'label-report-01-09-2021.csv',
};
actions.downloadLabelReports(1, param);
await flushPromises();
await actions.downloadLabelReports(1, param);
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -171,9 +59,7 @@ describe('#actions', () => {
to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv',
};
actions.downloadInboxReports(1, param);
await flushPromises();
await actions.downloadInboxReports(1, param);
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -192,9 +78,7 @@ describe('#actions', () => {
to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv',
};
actions.downloadInboxReports(1, param);
await flushPromises();
await actions.downloadInboxReports(1, param);
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -189,8 +189,6 @@ export default {
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
SET_BOT_SUMMARY_STATUS: 'SET_BOT_SUMMARY_STATUS',
SET_ACCOUNT_SUMMARY_STATUS: 'SET_ACCOUNT_SUMMARY_STATUS',
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
@@ -264,7 +264,7 @@ export default {
<div
v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-base leading-5 text-n-slate-12 [&>p>.link]:text-n-blue-text [&>p>.link]:hover:underline"
class="mb-4 text-base leading-5 pre-chat-header-message text-n-slate-12"
/>
<!-- 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,
@@ -354,4 +354,12 @@ export default {
.formkit-messages {
@apply list-none m-0 p-0;
}
@media (prefers-color-scheme: dark) {
.pre-chat-header-message {
.link {
@apply text-woot-500 underline;
}
}
}
</style>
+2 -3
View File
@@ -12,8 +12,7 @@ class SendReplyJob < ApplicationJob
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService
'Channel::Sms' => ::Sms::SendOnSmsService
}
case channel_name
@@ -28,7 +27,7 @@ class SendReplyJob < ApplicationJob
def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
::Instagram::SendOnInstagramService.new(message: message).perform
else
::Facebook::SendOnFacebookService.new(message: message).perform
end
+13 -153
View File
@@ -2,6 +2,10 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
include HTTParty
base_uri 'https://graph.facebook.com/v11.0/me'
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -14,64 +18,17 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end
end
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries)
entries.each do |entry|
process_single_entry(entry.with_indifferent_access)
end
end
private
def process_single_entry(entry)
if test_event?(entry)
process_test_event(entry)
return
end
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)
entry = entry.with_indifferent_access
messages(entry).each do |messaging|
send(@event_name, messaging) if event_name(messaging)
end
end
end
def agent_message_via_echo?(messaging)
messaging[:message].present? && messaging[:message][:is_echo].present?
end
def test_event?(entry)
entry[:changes].present?
end
def process_test_event(entry)
messaging = extract_messaging_from_test_event(entry)
Instagram::TestEventService.new(messaging).perform if messaging.present?
end
def extract_messaging_from_test_event(entry)
entry[:changes].first&.dig(:value) if entry[:changes].present?
end
def instagram_id(messaging)
if agent_message_via_echo?(messaging)
messaging[:sender][:id]
else
messaging[:recipient][:id]
end
end
private
def ig_account_id
@entries&.first&.dig(:id)
@@ -81,116 +38,19 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.dig(0, :messaging, 0, :sender, :id)
end
def find_channel(instagram_id)
# There will be chances for the instagram account to be connected to a facebook page,
# so we need to check for both instagram and facebook page channels
# priority is for instagram channel which created via instagram login
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
# If not found, fallback to the facebook page channel
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
channel
end
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging, channel)
if channel.is_a?(Channel::Instagram)
::Instagram::MessageText.new(messaging, channel).perform
else
::Instagram::Messenger::MessageText.new(messaging, channel).perform
end
def message(messaging)
::Instagram::MessageText.new(messaging).perform
end
def read(messaging, channel)
# Use a single service to handle read status for both channel types since the params are same
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
def read(messaging)
::Instagram::ReadStatusService.new(params: messaging).perform
end
def messages(entry)
(entry[:messaging].presence || entry[:standby] || [])
end
end
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
# [
# {
# "time": <timestamp>,
# "id": <INSTAGRAM_USER_ID>,
# "messaging": [
# {
# "sender": {
# "id": <INSTAGRAM_USER_ID>
# },
# "recipient": {
# "id": <INSTAGRAM_USER_ID>
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": <MESSAGE_ID>,
# "text": <MESSAGE_TEXT>
# }
# }
# ]
# }
# ]
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
# When testing in Facebook's developer dashboard, you'll get a Page-style
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
# arrive in the familiar Messenger format with a "messaging" array.
# This apparent inconsistency is actually by design - Instagram's webhooks
# use different formats for testing vs production to maintain compatibility
# with both Instagram Direct and Facebook Page integrations.
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
# Test response from via Instagram direct
# [
# {
# "id": "0",
# "time": <timestamp>,
# "changes": [
# {
# "field": "messages",
# "value": {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": "1527459824",
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# }
# ]
# }
# ]
# Test response via Facebook page
# [
# {
# "time": <timestamp>,,
# "id": "0",
# "messaging": [
# {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# ]
# }
# ]
-4
View File
@@ -1,8 +1,4 @@
class ConversationReplyMailer < ApplicationMailer
# We needs to expose large attachments to the view as links
# Small attachments are linked as mail attachments directly
attr_reader :large_attachments
include ConversationReplyMailerHelper
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
layout :choose_layout
@@ -17,44 +17,11 @@ module ConversationReplyMailerHelper
google_smtp_settings
set_delivery_method
# Email type detection logic:
# - email_reply: Sets @message with a single message
# - Other actions: Set @messages with a collection of messages
#
# So this check implicitly determines we're handling an email_reply
# and not one of the other email types (summary, transcript, etc.)
process_attachments_as_files_for_email_reply if @message&.attachments.present?
Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def google_smtp_settings
@@ -85,8 +52,6 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2'
}
end
+4
View File
@@ -33,6 +33,10 @@ class Channel::Api < ApplicationRecord
'API'
end
def messaging_window_enabled?
additional_attributes.present? && additional_attributes['agent_reply_time_window'].present?
end
private
def ensure_valid_agent_reply_time_window
+4
View File
@@ -32,6 +32,10 @@ class Channel::FacebookPage < ApplicationRecord
'Facebook'
end
def messaging_window_enabled?
false
end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
-8
View File
@@ -31,14 +31,6 @@ class Channel::Instagram < ApplicationRecord
'Instagram'
end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: name }
}).perform
end
def subscribe
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
HTTParty.post(
+4
View File
@@ -41,6 +41,10 @@ class Channel::TwilioSms < ApplicationRecord
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
def messaging_window_enabled?
medium == 'whatsapp'
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
+4
View File
@@ -46,6 +46,10 @@ class Channel::Whatsapp < ApplicationRecord
end
end
def messaging_window_enabled?
true
end
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
+4
View File
@@ -7,6 +7,10 @@ module Channelable
after_update :create_audit_log_entry
end
def messaging_window_enabled?
false
end
def create_audit_log_entry; end
end
+26 -1
View File
@@ -115,7 +115,14 @@ class Conversation < ApplicationRecord
delegate :auto_resolve_duration, to: :account
def can_reply?
Conversations::MessageWindowService.new(self).can_reply?
channel = inbox&.channel
return can_reply_on_instagram? if additional_attributes['type'] == 'instagram_direct_message'
return true unless channel&.messaging_window_enabled?
messaging_window = inbox.api? ? channel.additional_attributes['agent_reply_time_window'].to_i : 24
last_message_in_messaging_window?(messaging_window)
end
def language
@@ -130,6 +137,24 @@ class Conversation < ApplicationRecord
messages&.incoming&.last
end
def last_message_in_messaging_window?(time)
return false if last_incoming_message.nil?
Time.current < last_incoming_message.created_at + time.hours
end
def can_reply_on_instagram?
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return false if last_incoming_message.nil?
if global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
Time.current < last_incoming_message.created_at + 7.days
else
last_message_in_messaging_window?(24)
end
end
def toggle_status
# FIXME: implement state machine with aasm
self.status = open? ? :resolved : :open
@@ -1,64 +0,0 @@
class Conversations::MessageWindowService
MESSAGING_WINDOW_24_HOURS = 24.hours
MESSAGING_WINDOW_7_DAYS = 7.days
def initialize(conversation)
@conversation = conversation
end
def can_reply?
return true if messaging_window.blank?
last_message_in_messaging_window?(messaging_window)
end
private
def messaging_window
case @conversation.inbox.channel_type
when 'Channel::Api'
api_messaging_window
when 'Channel::FacebookPage'
messenger_messaging_window
when 'Channel::Instagram'
instagram_messaging_window
when 'Channel::Whatsapp'
MESSAGING_WINDOW_24_HOURS
when 'Channel::TwilioSms'
twilio_messaging_window
end
end
def last_message_in_messaging_window?(time)
return false if last_incoming_message.nil?
Time.current < last_incoming_message.created_at + time
end
def api_messaging_window
return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank?
@conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours
end
# Check medium of the inbox to determine the messaging window
def twilio_messaging_window
@conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil
end
def messenger_messaging_window
meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
end
def instagram_messaging_window
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
end
def meta_messaging_window(config_key)
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
end
def last_incoming_message
@last_incoming_message ||= @conversation.messages&.incoming&.last
end
end
@@ -1,69 +0,0 @@
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
@@ -1,95 +0,0 @@
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
+137 -40
View File
@@ -1,54 +1,90 @@
class Instagram::MessageText < Instagram::BaseMessageText
class Instagram::MessageText < Instagram::WebhooksBaseService
include HTTParty
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)
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
rescue StandardError, Koala::Facebook::ClientError => e
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{e.message}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end
find_or_create_contact(result) if defined?(result) && result.present?
end
# rubocop:enable Metrics/AbcSize
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
end
def fetch_instagram_user(ig_scope_id)
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return process_successful_response(response) if response.success?
handle_error_response(response)
{}
def message_is_deleted?
@messaging[:message][:is_deleted].present?
end
def process_successful_response(response)
result = JSON.parse(response.body).with_indifferent_access
{
'name' => result['name'],
'username' => result['username'],
'profile_pic' => result['profile_pic'],
'id' => result['id'],
'follower_count' => result['follower_count'],
'is_user_follow_business' => result['is_user_follow_business'],
'is_business_follow_user' => result['is_business_follow_user'],
'is_verified_user' => result['is_verified_user']
}.with_indifferent_access
# 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 handle_error_response(response)
parsed_response = response.parsed_response
error_message = parsed_response.dig('error', 'message')
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
def sent_via_test_webhook?
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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
def create_message
@@ -56,4 +92,65 @@ class Instagram::MessageText < Instagram::BaseMessageText
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
def create_test_text
return unless sent_via_test_webhook?
Rails.logger.info('Probably Test data.')
messenger_channel = Channel::FacebookPage.last
@inbox = ::Inbox.find_by(channel: messenger_channel)
return unless @inbox
@contact = create_test_contact
@conversation ||= create_test_conversation(conversation_params)
@message = @conversation.messages.create!(test_message_params)
end
def create_test_contact
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
unless @contact_inbox
@contact_inbox ||= @inbox.channel.create_contact_inbox(
'sender_username', 'sender_username'
)
end
@contact_inbox.contact
end
def create_test_conversation(conversation_params)
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
end
def test_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
source_id: @messaging[:message][:mid],
content: @messaging[:message][:text],
sender: @contact
}
end
def build_conversation(conversation_params)
Conversation.create!(
conversation_params.merge(
contact_inbox_id: @contact_inbox.id
)
)
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
end
@@ -1,37 +0,0 @@
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
@@ -1,40 +0,0 @@
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
private
def channel_class
Channel::FacebookPage
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_message(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
process_response(response, message_content)
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
end
end
@@ -1,8 +1,8 @@
class Instagram::ReadStatusService
pattr_initialize [:params!, :channel!]
pattr_initialize [:params!]
def perform
return if channel.blank?
return if instagram_channel.blank?
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
end
@@ -11,9 +11,13 @@ class Instagram::ReadStatusService
params[:recipient][:id]
end
def instagram_channel
@instagram_channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
end
def message
return unless params[:read][:mid]
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
@message ||= @instagram_channel.inbox.messages.find_by(source_id: params[:read][:mid])
end
end
@@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_at)
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
# 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now
@@ -1,30 +1,130 @@
class Instagram::SendOnInstagramService < Instagram::BaseSendService
class Instagram::SendOnInstagramService < Base::SendOnChannelService
include HTTParty
pattr_initialize [:message!]
base_uri 'https://graph.facebook.com/v11.0/me'
private
delegate :additional_attributes, to: :contact
def channel_class
Channel::Instagram
Channel::FacebookPage
end
def perform_reply
if message.attachments.present?
message.attachments.each do |attachment|
send_to_facebook_page attachment_message_params(attachment)
end
end
send_to_facebook_page message_params if message.content.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
# TODO : handle specific errors or else page will get disconnected
# channel.authorization_error!
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
end
# Deliver a message with the given payload.
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
def send_message(message_content)
access_token = channel.access_token
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_to_facebook_page(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token }
instagram_id = channel.instagram_id.presence || 'me'
query[:appsecret_proof] = app_secret_proof if app_secret_proof
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
response = HTTParty.post(
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
process_response(response, message_content)
handle_response(response, message_content)
end
def handle_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
message.update!(status: :failed, external_error: external_error)
nil
end
end
def external_error(response)
# https://developers.facebook.com/docs/instagram-api/reference/error-codes/
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
"#{error_code} - #{error_message}"
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
def conversation_type
conversation.additional_attributes['type']
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
end
def config
Facebook::Messenger.config
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
@@ -1,79 +0,0 @@
class Instagram::TestEventService
def initialize(messaging)
@messaging = messaging
end
def perform
Rails.logger.info("Processing Instagram test webhook event, #{@messaging}")
return false unless test_webhook_event?
create_test_text
end
private
def test_webhook_event?
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
end
def create_test_text
# As of now, we are using the last created instagram channel as the test channel,
# since we don't have any other channel for testing purpose at the time of meta approval
channel = Channel::Instagram.last
@inbox = ::Inbox.find_by(channel: 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
@@ -1,14 +1,9 @@
class Instagram::WebhooksBaseService
attr_reader :channel
def initialize(channel)
@channel = channel
end
private
def inbox_channel(_instagram_id)
@inbox = ::Inbox.find_by(channel: @channel)
def inbox_channel(instagram_id)
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
@inbox = ::Inbox.find_by(channel: messenger_channel)
end
def find_or_create_contact(user)
@@ -29,31 +24,9 @@ class Instagram::WebhooksBaseService
def update_instagram_profile_link(user)
return unless user['username']
instagram_attributes = build_instagram_attributes(user)
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
end
def build_instagram_attributes(user)
attributes = {
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
'social_profiles': { 'instagram': user['username'] },
'social_instagram_user_name': user['username']
}
# Add optional attributes if present
optional_fields = %w[
follower_count
is_user_follow_business
is_business_follow_user
is_verified_user
]
optional_fields.each do |field|
next if user[field].nil?
attributes["social_instagram_#{field}"] = user[field]
end
attributes
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
@contact.save
end
end
@@ -56,7 +56,6 @@ end
## Instagram Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
-4
View File
@@ -39,10 +39,6 @@ By default, it renders:
<% else %>
<title><%= @portal.page_title%></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
@@ -32,10 +32,9 @@
<% if message.content %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments.present? %>
<p>Attachments:</p>
<% if message.attachments %>
<% message.attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
Attachment [<a href="<%= attachment.file_url %>" _target="blank">Click here to view</a>]
<% end %>
<% end %>
<p style="font-size: 90%; font-size: 90%;color: #899096;margin-top: -8px; margin-bottom: 0px;">
@@ -1,9 +1,8 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% if @message.attachments %>
<% @message.attachments.each do |attachment| %>
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
<% end %>
<% end %>
@@ -20,10 +20,9 @@
<% if message.content.present? %>
<hr style="border: 0; border-bottom: 1px solid #AEC3D5;"/>
<% end %>
Attachments:
<% message.attachments.each_with_index do |attachment, index| %>
<% if index > 0 %><br /><% end %>
<a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a>
This message contains <%= message.attachments.count > 1 ? 'attachments' : 'an attachment' %>.
<% message.attachments.each do |attachment| %>
<br />- View the attachment <a href="<%= attachment.file_url %>" _target="blank">here</a>.
<% end %>
</p>
<% end %>
@@ -3,10 +3,9 @@
<% if message.content %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments.present? %>
<p>Attachments:</p>
<% if message.attachments %>
<% message.attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
<% end %>
<% end %>
</p>
@@ -85,15 +85,15 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
+2 -2
View File
@@ -138,7 +138,7 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"vite": "^5.4.17",
"vite": "^5.4.15",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
@@ -154,7 +154,7 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.17",
"vite": "5.4.15",
"vitest": "3.0.5"
}
},
+130 -125
View File
@@ -6,7 +6,7 @@ settings:
overrides:
vite-node: 2.0.1
vite: 5.4.17
vite: 5.4.15
vitest: 3.0.5
importers:
@@ -72,7 +72,7 @@ importers:
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.1.4
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))
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))
'@vue/compiler-sfc':
specifier: ^3.5.8
version: 3.5.8
@@ -238,7 +238,7 @@ importers:
version: 1.8.1(tailwindcss@3.4.13)
'@histoire/plugin-vue':
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.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))
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))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -301,7 +301,7 @@ importers:
version: 6.0.0
histoire:
specifier: 0.17.15
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))
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))
husky:
specifier: ^7.0.0
version: 7.0.4
@@ -330,11 +330,11 @@ importers:
specifier: ^3.4.13
version: 3.4.13
vite:
specifier: 5.4.17
version: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
specifier: 5.4.15
version: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
version: 5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
version: 5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
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)
@@ -860,7 +860,7 @@ packages:
'@histoire/shared@0.17.17':
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
peerDependencies:
vite: 5.4.17
vite: 5.4.15
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
@@ -1039,103 +1039,103 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
'@rollup/rollup-android-arm-eabi@4.39.0':
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
'@rollup/rollup-android-arm-eabi@4.37.0':
resolution: {integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.39.0':
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
'@rollup/rollup-android-arm64@4.37.0':
resolution: {integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.39.0':
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
'@rollup/rollup-darwin-arm64@4.37.0':
resolution: {integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.39.0':
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
'@rollup/rollup-darwin-x64@4.37.0':
resolution: {integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.39.0':
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
'@rollup/rollup-freebsd-arm64@4.37.0':
resolution: {integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.39.0':
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
'@rollup/rollup-freebsd-x64@4.37.0':
resolution: {integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
resolution: {integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
resolution: {integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.39.0':
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
'@rollup/rollup-linux-arm64-gnu@4.37.0':
resolution: {integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.39.0':
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
'@rollup/rollup-linux-arm64-musl@4.37.0':
resolution: {integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
resolution: {integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
resolution: {integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
resolution: {integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.39.0':
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
'@rollup/rollup-linux-riscv64-musl@4.37.0':
resolution: {integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.39.0':
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
'@rollup/rollup-linux-s390x-gnu@4.37.0':
resolution: {integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
'@rollup/rollup-linux-x64-gnu@4.37.0':
resolution: {integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.39.0':
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
'@rollup/rollup-linux-x64-musl@4.37.0':
resolution: {integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.39.0':
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
'@rollup/rollup-win32-arm64-msvc@4.37.0':
resolution: {integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.39.0':
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
'@rollup/rollup-win32-ia32-msvc@4.37.0':
resolution: {integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.39.0':
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
'@rollup/rollup-win32-x64-msvc@4.37.0':
resolution: {integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==}
cpu: [x64]
os: [win32]
@@ -1746,6 +1746,9 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1800,7 +1803,7 @@ packages:
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: 5.4.17
vite: 5.4.15
vue: ^3.2.25
'@vitest/coverage-v8@3.0.5':
@@ -1819,7 +1822,7 @@ packages:
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
msw: ^2.4.9
vite: 5.4.17
vite: 5.4.15
peerDependenciesMeta:
msw:
optional: true
@@ -3100,7 +3103,7 @@ packages:
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
hasBin: true
peerDependencies:
vite: 5.4.17
vite: 5.4.15
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -4344,8 +4347,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@4.39.0:
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
rollup@4.37.0:
resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4863,10 +4866,10 @@ packages:
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
peerDependencies:
vite: 5.4.17
vite: 5.4.15
vite@5.4.17:
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
vite@5.4.15:
resolution: {integrity: sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5672,10 +5675,10 @@ snapshots:
highlight.js: 11.10.0
vue: 3.5.12(typescript@5.6.2)
'@histoire/app@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/app@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@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.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/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
flexsearch: 0.7.21
@@ -5683,7 +5686,7 @@ snapshots:
transitivePeerDependencies:
- vite
'@histoire/controls@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))':
dependencies:
'@codemirror/commands': 6.7.0
'@codemirror/lang-json': 6.0.1
@@ -5692,26 +5695,26 @@ snapshots:
'@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.34.1
'@histoire/shared': 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/vendors': 0.17.17
transitivePeerDependencies:
- 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.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))':
'@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))':
dependencies:
'@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.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/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
change-case: 4.1.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.17(@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.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
launch-editor: 2.9.1
pathe: 1.1.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- vite
'@histoire/shared@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))':
dependencies:
'@histoire/vendors': 0.17.17
'@types/fs-extra': 9.0.13
@@ -5719,7 +5722,7 @@ snapshots:
chokidar: 3.6.0
pathe: 1.1.2
picocolors: 1.1.0
vite: 5.4.17(@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/vendors@0.17.17': {}
@@ -5944,64 +5947,64 @@ snapshots:
'@rails/ujs@7.1.400': {}
'@rollup/rollup-android-arm-eabi@4.39.0':
'@rollup/rollup-android-arm-eabi@4.37.0':
optional: true
'@rollup/rollup-android-arm64@4.39.0':
'@rollup/rollup-android-arm64@4.37.0':
optional: true
'@rollup/rollup-darwin-arm64@4.39.0':
'@rollup/rollup-darwin-arm64@4.37.0':
optional: true
'@rollup/rollup-darwin-x64@4.39.0':
'@rollup/rollup-darwin-x64@4.37.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.39.0':
'@rollup/rollup-freebsd-arm64@4.37.0':
optional: true
'@rollup/rollup-freebsd-x64@4.39.0':
'@rollup/rollup-freebsd-x64@4.37.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.39.0':
'@rollup/rollup-linux-arm64-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.39.0':
'@rollup/rollup-linux-arm64-musl@4.37.0':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.39.0':
'@rollup/rollup-linux-riscv64-musl@4.37.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.39.0':
'@rollup/rollup-linux-s390x-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.39.0':
'@rollup/rollup-linux-x64-gnu@4.37.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.39.0':
'@rollup/rollup-linux-x64-musl@4.37.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.39.0':
'@rollup/rollup-win32-arm64-msvc@4.37.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.39.0':
'@rollup/rollup-win32-ia32-msvc@4.37.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.39.0':
'@rollup/rollup-win32-x64-msvc@4.37.0':
optional: true
'@rtsao/scc@1.1.0': {}
@@ -6744,6 +6747,8 @@ snapshots:
'@tootallnate/once@2.0.0': {}
'@types/estree@1.0.6': {}
'@types/estree@1.0.7': {}
'@types/flexsearch@0.7.6': {}
@@ -6802,9 +6807,9 @@ snapshots:
global: 4.4.0
is-function: 1.0.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))':
'@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))':
dependencies:
vite: 5.4.17(@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)
'@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))':
@@ -6832,13 +6837,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
'@vitest/mocker@3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@vitest/mocker@3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.17(@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)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -8393,12 +8398,12 @@ snapshots:
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.17(@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.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
'@akryum/tinypool': 0.3.1
'@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.17(@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/app': 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.15(@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/vendors': 0.17.17
'@types/flexsearch': 0.7.6
'@types/markdown-it': 12.2.3
@@ -8425,7 +8430,7 @@ snapshots:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.4
vite: 5.4.17(@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-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
@@ -9766,30 +9771,30 @@ snapshots:
dependencies:
glob: 7.2.3
rollup@4.39.0:
rollup@4.37.0:
dependencies:
'@types/estree': 1.0.7
'@types/estree': 1.0.6
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.39.0
'@rollup/rollup-android-arm64': 4.39.0
'@rollup/rollup-darwin-arm64': 4.39.0
'@rollup/rollup-darwin-x64': 4.39.0
'@rollup/rollup-freebsd-arm64': 4.39.0
'@rollup/rollup-freebsd-x64': 4.39.0
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
'@rollup/rollup-linux-arm64-gnu': 4.39.0
'@rollup/rollup-linux-arm64-musl': 4.39.0
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-musl': 4.39.0
'@rollup/rollup-linux-s390x-gnu': 4.39.0
'@rollup/rollup-linux-x64-gnu': 4.39.0
'@rollup/rollup-linux-x64-musl': 4.39.0
'@rollup/rollup-win32-arm64-msvc': 4.39.0
'@rollup/rollup-win32-ia32-msvc': 4.39.0
'@rollup/rollup-win32-x64-msvc': 4.39.0
'@rollup/rollup-android-arm-eabi': 4.37.0
'@rollup/rollup-android-arm64': 4.37.0
'@rollup/rollup-darwin-arm64': 4.37.0
'@rollup/rollup-darwin-x64': 4.37.0
'@rollup/rollup-freebsd-arm64': 4.37.0
'@rollup/rollup-freebsd-x64': 4.37.0
'@rollup/rollup-linux-arm-gnueabihf': 4.37.0
'@rollup/rollup-linux-arm-musleabihf': 4.37.0
'@rollup/rollup-linux-arm64-gnu': 4.37.0
'@rollup/rollup-linux-arm64-musl': 4.37.0
'@rollup/rollup-linux-loongarch64-gnu': 4.37.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.37.0
'@rollup/rollup-linux-riscv64-gnu': 4.37.0
'@rollup/rollup-linux-riscv64-musl': 4.37.0
'@rollup/rollup-linux-s390x-gnu': 4.37.0
'@rollup/rollup-linux-x64-gnu': 4.37.0
'@rollup/rollup-linux-x64-musl': 4.37.0
'@rollup/rollup-win32-arm64-msvc': 4.37.0
'@rollup/rollup-win32-ia32-msvc': 4.37.0
'@rollup/rollup-win32-x64-msvc': 4.37.0
fsevents: 2.3.3
rope-sequence@1.3.2: {}
@@ -10378,7 +10383,7 @@ snapshots:
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.17(@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)
transitivePeerDependencies:
- '@types/node'
- less
@@ -10390,19 +10395,19 @@ snapshots:
- supports-color
- terser
vite-plugin-ruby@5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
vite-plugin-ruby@5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
fast-glob: 3.3.2
vite: 5.4.17(@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)
transitivePeerDependencies:
- supports-color
vite@5.4.17(@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):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.39.0
rollup: 4.37.0
optionalDependencies:
'@types/node': 22.7.0
fsevents: 2.3.3
@@ -10412,7 +10417,7 @@ snapshots:
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
dependencies:
'@vitest/expect': 3.0.5
'@vitest/mocker': 3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/mocker': 3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -10428,7 +10433,7 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 5.4.17(@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-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
why-is-node-running: 2.3.0
optionalDependencies:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
@@ -95,33 +95,5 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
instagram_source_id = '123456789'
# Create a Facebook page inbox with a contact using the same source_id
facebook_inbox = create(:inbox, channel_type: 'Channel::FacebookPage', account: account)
facebook_contact = create(:contact, account: account)
facebook_contact_inbox = create(:contact_inbox, contact: facebook_contact, inbox: facebook_inbox, source_id: instagram_source_id)
# Create an Instagram inbox
instagram_inbox = create(:inbox, channel_type: 'Channel::Instagram', account: account)
# Try to create a contact inbox with same source_id for Instagram
contact_inbox = described_class.new(
source_id: instagram_source_id,
inbox: instagram_inbox,
contact_attributes: {
name: 'Instagram User',
email: 'instagram_user@example.com'
}
).perform
# Should reuse the existing contact from Facebook
expect(contact_inbox.contact.id).to eq(facebook_contact.id)
# Make sure the contact inbox is not the same as the Facebook contact inbox
expect(contact_inbox.id).not_to eq(facebook_contact_inbox.id)
expect(contact_inbox.inbox_id).to eq(instagram_inbox.id)
end
end
end
@@ -1,26 +1,27 @@
require 'rails_helper'
describe Messages::Instagram::MessageBuilder do
subject(:instagram_direct_message_builder) { described_class }
subject(:instagram_message_builder) { described_class }
before do
stub_request(:post, /graph\.instagram\.com/)
stub_request(:post, /graph.facebook.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let!(: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(:fb_object) { double }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
let(: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
let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
@@ -28,27 +29,16 @@ describe Messages::Instagram::MessageBuilder do
end
describe '#perform' do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
.to_return(
status: 200,
body: {
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: 'Sender-id-1',
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates contact and message for the instagram direct inbox' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
@@ -58,19 +48,30 @@ describe Messages::Instagram::MessageBuilder do
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
message = instagram_inbox.messages.first
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 is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
conversation
message
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
instagram_inbox.reload
@@ -80,244 +81,220 @@ describe Messages::Instagram::MessageBuilder do
end
it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
message = instagram_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 story id' do
story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
status: 200,
body: {
story: {
mention: {
id: 'chatwoot-app-user-id-1'
}
},
from: {
username: instagram_inbox.channel.instagram_id
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
it 'creates message with for reply with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
message = instagram_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(first_messaging, instagram_inbox).perform
it 'creates message with for reply with mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_inbox).perform
# Create second message with reply to mid
# 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_inbox).perform
first_message = instagram_channel.inbox.messages.first
message = instagram_channel.inbox.messages.last
first_message = instagram_inbox.messages.first
reply_message = instagram_inbox.messages.last
expect(reply_message.content).to eq('This is message with replyto mid')
expect(reply_message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
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 'handles deleted story' do
story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
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]
described_class.new(messaging, instagram_inbox).perform
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
message = instagram_inbox.messages.first
instagram_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_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
expect(instagram_inbox.conversations.count).to be 1
instagram_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_inbox.conversations.count).to be 0
expect(instagram_inbox.messages.count).to be 0
end
it 'does not create message if the message is already exists' do
message
contact = instagram_channel.inbox.contacts.first
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
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
inital_count = Conversation.count
message = 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.count).to eq(1)
expect(Conversation.count).to eq(initial_count + 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_inbox.id,
contact_id: contact.id, status: :open)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
message = dm_params[:entry][0]['messaging'][0]
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)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
inital_count = Conversation.count
message = 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).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(initial_count + 1)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
inital_count = Conversation.count
message = 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.count).to eq(1)
expect(Conversation.count).to eq(initial_count + 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_inbox.id,
contact_id: contact.id, status: :resolved)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
inital_count = Conversation.count
described_class.new(messaging, instagram_inbox).perform
message = dm_params[:entry][0]['messaging'][0]
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(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)
expect(Conversation.count).to eq(inital_count)
end
end
end
@@ -1,378 +0,0 @@
require 'rails_helper'
describe Messages::Instagram::Messenger::MessageBuilder do
subject(:instagram_message_builder) { described_class }
before do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
end
let!(:account) { create(:account) }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_messenger_inbox.id, source_id: 'Sender-id-1') }
let(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
end
let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
end
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
message
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: true).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
end
it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with for reply with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_messenger_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with for reply with mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_messenger_inbox).perform
# create the second message with the reply to mid set
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
first_message = instagram_messenger_channel.inbox.messages.first
message = instagram_messenger_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid')
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
end
it 'raises exception on deleted story' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 0
contact = instagram_messenger_channel.inbox.contacts.first
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
describe '#fetch_story_link' do
before do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
end
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
it 'saves story information when story mention is processed' do
allow(fb_object).to receive(:get_object).and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
builder = described_class.new(messaging, instagram_messenger_inbox)
builder.perform
message = instagram_messenger_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles story mentions specifically in the Instagram builder' do
# First allow contact info fetch
allow(fb_object).to receive(:get_object).and_return({
name: 'Jane',
id: 'Sender-id-1'
}.with_indifferent_access)
# Then allow story data fetch
allow(fb_object).to receive(:get_object).with(anything, fields: %w[story from])
.and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_inbox.messages.first
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
end
end
@@ -34,21 +34,25 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { false }
it 'includes correct stats for each inbox' do
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
@@ -56,21 +60,25 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { true }
it 'uses business hours values for calculations' do
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end

Some files were not shown because too many files have changed in this diff Show More