Compare commits

..
14 Commits
178 changed files with 3124 additions and 4892 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# # # #
# # Linux nightly installer action # # Linux nightly installer action
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1
View File
@@ -174,6 +174,7 @@ gem 'pgvector'
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby_llm', '1.1.0rc1'
gem 'ruby-openai' gem 'ruby-openai'
gem 'shopify_api' gem 'shopify_api'
+8
View File
@@ -698,6 +698,13 @@ GEM
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.1.0rc1)
base64
event_stream_parser (~> 1)
faraday (~> 2)
faraday-multipart (~> 1)
faraday-retry (~> 2)
zeitwerk (~> 2)
ruby_parser (3.20.0) ruby_parser (3.20.0)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sass (3.7.4) sass (3.7.4)
@@ -964,6 +971,7 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai ruby-openai
ruby_llm (= 1.1.0rc1)
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?
@@ -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) 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 end
private private
def get_story_object_from_source_id(source_id) def attachments
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" @messaging[:message][:attachments] || {}
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def handle_error_response(response) def message_type
parsed_response = JSON.parse(response.body) @outgoing_echo ? :outgoing : :incoming
error_code = parsed_response.dig('error', 'code') end
# https://developers.facebook.com/docs/messenger-platform/error-codes def message_identifier
# Access token has expired or become invalid. message[:mid]
channel.authorization_error! if error_code == 190 end
# There was a problem scraping data from the provided link. def message_source_id
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 @outgoing_echo ? recipient_id : sender_id
if error_code == 1_609_005 end
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) 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 end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end end
def base_uri def find_or_build_for_multiple_conversations
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" 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 end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end end
@@ -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! message.save!
end 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 end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def allowed_agent_params def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline] [:name, :email, :name, :role, :availability, :auto_offline]
end end
def agent_params def agent_params
@@ -1,5 +1,6 @@
module InstagramConcern module InstagramConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
include HTTParty
def instagram_client def instagram_client
::OAuth2::Client.new( ::OAuth2::Client.new(
+1 -2
View File
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE', 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV', 'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN' 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
).merge(app_config) ).merge(app_config)
end end
@@ -65,7 +65,6 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key, VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def validate_business_account? def validate_business_account?
# return true if the user is a business account, false if it is a gmail 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 end
def create_account_for_user def create_account_for_user
-17
View File
@@ -1,26 +1,9 @@
/* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient { class AgentBotsAPI extends ApiClient {
constructor() { constructor() {
super('agent_bots', { accountScoped: true }); super('agent_bots', { accountScoped: true });
} }
create(data) {
return axios.post(this.url, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
@@ -29,19 +29,6 @@
--iris-11: 87 83 198; --iris-11: 87 83 198;
--iris-12: 39 41 98; --iris-12: 39 41 98;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--ruby-1: 255 252 253; --ruby-1: 255 252 253;
--ruby-2: 255 247 248; --ruby-2: 255 247 248;
--ruby-3: 254 234 237; --ruby-3: 254 234 237;
@@ -144,19 +131,6 @@
--iris-11: 158 177 255; --iris-11: 158 177 255;
--iris-12: 224 223 254; --iris-12: 224 223 254;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--ruby-1: 25 17 19; --ruby-1: 25 17 19;
--ruby-2: 30 21 23; --ruby-2: 30 21 23;
--ruby-3: 58 20 30; --ruby-3: 58 20 30;
@@ -51,7 +51,6 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button <Button
:label="t('DIALOG.BUTTONS.CANCEL')" :label="t('DIALOG.BUTTONS.CANCEL')"
variant="link" variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand" class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog" @click="closeDialog"
/> />
@@ -31,6 +31,10 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email', value: 'email',
}, },
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{ {
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name', value: 'company_name',
@@ -33,8 +33,6 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); 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="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div> <div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11"> <div v-dompurify-html="messageContent" class="prose-sm break-words" />
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -125,10 +125,7 @@ defineExpose({ open, close });
<slot /> <slot />
<!-- Dialog content will be injected here --> <!-- Dialog content will be injected here -->
<slot name="footer"> <slot name="footer">
<div <div class="flex items-center justify-between w-full gap-3">
v-if="showCancelButton || showConfirmButton"
class="flex items-center justify-between w-full gap-3"
>
<Button <Button
v-if="showCancelButton" v-if="showCancelButton"
variant="faded" variant="faded"
@@ -12,7 +12,6 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill', 'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
}; };
const providerIconMap = { const providerIconMap = {
@@ -19,17 +19,10 @@ const {
isAWebWidgetInbox, isAWebWidgetInbox,
isAWhatsAppChannel, isAWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAInstagramChannel,
} = useInbox(); } = useInbox();
const { const { status, isPrivate, createdAt, sourceId, messageType } =
status, useMessageContext();
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const readableTime = computed(() => const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a') messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -37,11 +30,6 @@ const readableTime = computed(() =>
const showStatusIndicator = computed(() => { const showStatusIndicator = computed(() => {
if (isPrivate.value) return false; 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.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true; if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
@@ -59,8 +47,7 @@ const isSent = computed(() => {
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value ||
isASmsInbox.value || isASmsInbox.value ||
isATelegramChannel.value || isATelegramChannel.value
isAInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT; return sourceId.value && status.value === MESSAGE_STATUS.SENT;
} }
@@ -99,8 +86,7 @@ const isRead = computed(() => {
if ( if (
isAWhatsAppChannel.value || isAWhatsAppChannel.value ||
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value
isAInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.READ; return sourceId.value && status.value === MESSAGE_STATUS.READ;
} }
@@ -116,6 +102,7 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ; if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED; if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT; if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS; return MESSAGE_STATUS.PROGRESS;
}); });
@@ -1,24 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
defineEmits(['toggle']);
</script>
<template>
<span>
<span
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
@click="$emit('toggle')"
>
{{
showingOriginal
? $t('CONVERSATION.VIEW_TRANSLATED')
: $t('CONVERSATION.VIEW_ORIGINAL')
}}
</span>
</span>
</template>
@@ -9,11 +9,9 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue'; import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue'; import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js'; import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } = const { content, contentAttributes, attachments, messageType } =
useMessageContext(); useMessageContext();
@@ -21,77 +19,35 @@ const { content, contentAttributes, attachments, messageType } =
const isExpandable = ref(false); const isExpandable = ref(false);
const isExpanded = ref(false); const isExpanded = ref(false);
const showQuotedMessage = ref(false); const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer'); const contentContainer = useTemplateRef('contentContainer');
onMounted(() => { onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400; isExpandable.value = contentContainer.value?.scrollHeight > 400;
}); });
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const isOutgoing = computed(() => {
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isIncoming = computed(() => !isOutgoing.value); const isIncoming = computed(() => !isOutgoing.value);
const { hasTranslations, translationContent } = const textToShow = computed(() => {
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text = const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value; contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>'); return text?.replace(/\n/g, '<br>');
}); });
const originalEmailHtml = computed( // Use TextContent as the default to fullHTML
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
);
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => { const fullHTML = computed(() => {
// If translations exist and we're showing translations (not original) return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
}); });
const unquotedHTML = computed(() => const unquotedHTML = computed(() => {
EmailQuoteExtractor.extractQuotes(fullHTML.value) return EmailQuoteExtractor.extractQuotes(fullHTML.value);
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
}); });
const handleSeeOriginal = () => { const hasQuotedMessage = computed(() => {
renderOriginal.value = !renderOriginal.value; return EmailQuoteExtractor.hasQuotes(fullHTML.value);
}; });
</script> </script>
<template> <template>
@@ -119,7 +75,7 @@ const handleSeeOriginal = () => {
> >
<div <div
v-if="isExpandable && !isExpanded" 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 <button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2" class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -132,12 +88,11 @@ const handleSeeOriginal = () => {
<FormattedContent <FormattedContent
v-if="isOutgoing && content" v-if="isOutgoing && content"
class="text-n-slate-12" class="text-n-slate-12"
:content="messageContent" :content="content"
/> />
<template v-else> <template v-else>
<Letter <Letter
v-if="showQuotedMessage" v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[ :allowed-css-properties="[
...allowedCssProperties, ...allowedCssProperties,
@@ -149,7 +104,6 @@ const handleSeeOriginal = () => {
/> />
<Letter <Letter
v-else v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML" :html="unquotedHTML"
:allowed-css-properties="[ :allowed-css-properties="[
@@ -181,12 +135,6 @@ const handleSeeOriginal = () => {
</button> </button>
</div> </div>
</section> </section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section <section
v-if="Array.isArray(attachments) && attachments.length" v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2" class="px-4 pb-4 space-y-2"
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue'; import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants'; import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } = const { content, attachments, contentAttributes, messageType } =
useMessageContext(); useMessageContext();
const { hasTranslations, translationContent } = const hasTranslations = computed(() => {
useTranslations(contentAttributes); const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false); const renderOriginal = ref(false);
@@ -22,7 +22,8 @@ const renderContent = computed(() => {
} }
if (hasTranslations.value) { if (hasTranslations.value) {
return translationContent.value; const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
} }
return content.value; return content.value;
@@ -36,6 +37,12 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length; return !content.value && !attachments.value?.length;
}); });
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => { const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value; renderOriginal.value = !renderOriginal.value;
}; };
@@ -48,12 +55,15 @@ const handleSeeOriginal = () => {
{{ $t('CONVERSATION.NO_CONTENT') }} {{ $t('CONVERSATION.NO_CONTENT') }}
</span> </span>
<FormattedContent v-if="renderContent" :content="renderContent" /> <FormattedContent v-if="renderContent" :content="renderContent" />
<TranslationToggle <span class="-mt-3">
v-if="hasTranslations" <span
class="-mt-3" v-if="hasTranslations"
:showing-original="renderOriginal" class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@toggle="handleSeeOriginal" @click="handleSeeOriginal"
/> >
{{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" /> <AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate"> <template v-if="isTemplate">
<div <div
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12 docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12', json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-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]', ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]', pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]', rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
} }
}; };
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar // We're using localStorage to store the expanded item in the sidebar
@@ -103,7 +116,32 @@ const newReportRoutes = () => [
}, },
]; ];
const reportRoutes = computed(() => newReportRoutes()); const oldReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
);
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
@@ -127,6 +127,7 @@ const settings = accountId => ({
meta: { meta: {
permissions: ['administrator'], permissions: ['administrator'],
}, },
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`), toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots', toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS, featureFlag: FEATURE_FLAGS.AGENT_BOTS,
@@ -49,6 +49,13 @@ export default {
return !!this.menuItem.children; return !!this.menuItem.children;
}, },
isMenuItemVisible() { isMenuItemVisible() {
if (this.menuItem.globalConfigFlag) {
// this checks for the `csmlEditorHost` flag in the global config
// if this is present, we toggle the CSML editor menu item
// TODO: This is very specific, and can be handled better, fix it
return !!this.globalConfig[this.menuItem.globalConfigFlag];
}
let isFeatureEnabled = true; let isFeatureEnabled = true;
if (this.menuItem.featureFlag) { if (this.menuItem.featureFlag) {
isFeatureEnabled = this.isFeatureEnabledonAccount( isFeatureEnabled = this.isFeatureEnabledonAccount(
@@ -218,14 +218,14 @@ const emitDateRange = () => {
/> />
<div <div
v-if="showDatePicker" v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl 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 <CalendarDateRange
:selected-range="selectedRange" :selected-range="selectedRange"
@set-range="setDateRange" @set-range="setDateRange"
/> />
<div <div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-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"> <div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates --> <!-- Calendars for Start and End Dates -->
@@ -251,12 +251,12 @@ const emitDateRange = () => {
@validate="updateManualInput($event, calendar)" @validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)" @error="handleManualInputError($event)"
/> />
<div class="py-5 border-b border-n-strong"> <div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div <div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]" class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class=" :class="
calendar === START_CALENDAR && calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-n-strong' 'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
" "
> >
<CalendarYear <CalendarYear
@@ -1,8 +1,6 @@
<script setup> <script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper'; import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
calendarType: { calendarType: {
type: String, type: String,
@@ -40,38 +38,42 @@ const onClickSetView = (type, mode) => {
<template> <template>
<div class="flex items-start justify-between w-full h-9"> <div class="flex items-start justify-between w-full h-9">
<NextButton <button
slate class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="onClickPrev(calendarType)" @click="onClickPrev(calendarType)"
/> >
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
v-if="firstButtonLabel" v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-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)" @click="onClickSetView(calendarType, viewMode)"
> >
{{ firstButtonLabel }} {{ firstButtonLabel }}
</button> </button>
<button <button
v-if="buttonLabel" v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12" class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-n-brand': viewMode }" :class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)" @click="onClickSetView(calendarType, YEAR)"
> >
{{ buttonLabel }} {{ buttonLabel }}
</button> </button>
</div> </div>
<NextButton <button
slate class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click="onClickNext(calendarType)" @click="onClickNext(calendarType)"
/> >
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div> </div>
</template> </template>
@@ -65,7 +65,7 @@ const validateDate = () => {
<input <input
v-model="localDateValue" v-model="localDateValue"
type="text" type="text"
class="!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" :placeholder="dateFormat"
:disabled="isDisabled" :disabled="isDisabled"
@keypress.enter="validateDate" @keypress.enter="validateDate"
@@ -18,7 +18,7 @@ const setDateRange = range => {
<template> <template>
<div class="w-[200px] flex flex-col items-start"> <div class="w-[200px] flex flex-col items-start">
<h4 <h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-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') }} {{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4> </h4>
@@ -26,11 +26,11 @@ const setDateRange = range => {
<button <button
v-for="range in dateRanges" v-for="range in dateRanges"
:key="range.label" :key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-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=" :class="
range.value === selectedRange range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active' ? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-n-slate-12' : 'text-slate-600 dark:text-slate-200'
" "
@click="setDateRange(range)" @click="setDateRange(range)"
> >
@@ -1,6 +1,4 @@
<script setup> <script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']); const emit = defineEmits(['clear', 'change']);
const onClickClear = () => { const onClickClear = () => {
@@ -13,19 +11,18 @@ const onClickApply = () => {
</script> </script>
<template> <template>
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center"> <div class="h-[56px] flex justify-between px-5 py-3 items-center">
<NextButton <button
slate 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"
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
@click="onClickClear" @click="onClickClear"
/> >
<NextButton {{ $t('DATE_PICKER.CLEAR_BUTTON') }}
sm </button>
ghost <button
:label="$t('DATE_PICKER.APPLY_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" @click="onClickApply"
/> >
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div> </div>
</template> </template>
@@ -71,12 +71,10 @@ const selectMonth = index => {
<button <button
v-for="(month, index) in months" v-for="(month, index) in months"
:key="index" :key="index"
class="p-2 text-sm font-medium text-center text-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="{ :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, index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}" }"
@click="selectMonth(index)" @click="selectMonth(index)"
> >
@@ -107,16 +107,17 @@ const isNextDayInRange = day => {
}; };
const dayClasses = day => ({ const dayClasses = day => ({
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day), 'text-slate-500 dark:text-slate-400 pointer-events-none':
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7': !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), isInCurrentMonth(day),
'bg-n-brand text-white': 'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day), isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5': 'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) && (isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) && !isSelectedStartOrEndDate(day) &&
isInCurrentMonth(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), isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
}); });
</script> </script>
@@ -163,7 +164,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) && !isLastDayOfMonth(day) &&
isInCurrentMonth(day) isInCurrentMonth(day)
" "
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-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>
</div> </div>
@@ -72,10 +72,10 @@ const selectYear = year => {
<button <button
v-for="year in years" v-for="year in years"
:key="year" :key="year"
class="p-2 text-sm font-medium text-center text-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="{ :class="{
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear, 'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear, year === activeYear,
}" }"
@click="selectYear(year)" @click="selectYear(year)"
> >
@@ -48,7 +48,7 @@ const openDatePicker = () => {
<template> <template>
<button <button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-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" @click="openDatePicker"
> >
<fluent-icon <fluent-icon
@@ -17,9 +17,6 @@ export default {
hasFbConfigured() { hasFbConfigured() {
return window.chatwootConfig?.fbAppId; return window.chatwootConfig?.fbAppId;
}, },
hasInstagramConfigured() {
return window.chatwootConfig?.instagramAppId;
},
isActive() { isActive() {
const { key } = this.channel; const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) { if (Object.keys(this.enabledFeatures).length === 0) {
@@ -35,12 +32,6 @@ export default {
return this.enabledFeatures.channel_email; return this.enabledFeatures.channel_email;
} }
if (key === 'instagram') {
return (
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
return [ return [
'website', 'website',
'twilio', 'twilio',
@@ -49,7 +40,6 @@ export default {
'sms', 'sms',
'telegram', 'telegram',
'line', 'line',
'instagram',
].includes(key); ].includes(key);
}, },
}, },
@@ -220,7 +220,6 @@ const plugins = computed(() => {
trigger: '@', trigger: '@',
showMenu: showUserMentions, showMenu: showUserMentions,
searchTerm: mentionSearchKey, searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}), }),
createSuggestionPlugin({ createSuggestionPlugin({
trigger: '/', trigger: '/',
@@ -775,24 +774,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
} }
.ProseMirror-prompt { .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 { h5 {
@apply text-n-slate-12 mb-1.5; @apply dark:text-slate-25 text-slate-800;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
} }
} }
@@ -328,24 +328,11 @@ export default {
} }
.ProseMirror-prompt { .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; z-index: var(--z-index-highest);
background: var(--white);
h5 { box-shadow: var(--shadow-large);
@apply text-n-slate-12 mb-1.5; border-radius: var(--border-radius-normal);
} border: 1px solid var(--color-border);
min-width: 25rem;
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
} }
</style> </style>
@@ -10,7 +10,6 @@ import {
ALLOWED_FILE_TYPES, ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE, ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue'; import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue'; import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -114,10 +113,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
conversationType: {
type: String,
default: '',
},
}, },
emits: [ emits: [
'replaceText', 'replaceText',
@@ -192,9 +187,6 @@ export default {
showAudioPlayStopButton() { showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio; return this.showAudioRecorder && this.isRecordingAudio;
}, },
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() { allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) { if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP; return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -202,10 +194,6 @@ export default {
if (this.isALineChannel) { if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE; return ALLOWED_FILE_TYPES_FOR_LINE;
} }
if (this.isAInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
return ALLOWED_FILE_TYPES; return ALLOWED_FILE_TYPES;
}, },
enableDragAndDrop() { enableDragAndDrop() {
@@ -36,7 +36,6 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -210,17 +209,6 @@ export default {
return contactLastSeenAt; return contactLastSeenAt;
}, },
// Check there is a instagram inbox exists 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() { replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -230,37 +218,24 @@ export default {
if (additionalAttributes) { if (additionalAttributes) {
const { const {
agent_reply_time_window_message: agentReplyTimeWindowMessage, agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes; } = additionalAttributes;
return ( return agentReplyTimeWindowMessage;
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
} }
return ''; return '';
} }
return this.$t('CONVERSATION.CANNOT_REPLY'); return this.$t('CONVERSATION.CANNOT_REPLY');
}, },
replyWindowLink() { replyWindowLink() {
if (this.isAFacebookInbox || this.isAInstagramChannel) { if (this.isAWhatsAppChannel) {
return REPLY_POLICY.FACEBOOK; return REPLY_POLICY.FACEBOOK;
} }
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) { if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP; return REPLY_POLICY.TWILIO_WHATSAPP;
} }
return ''; return '';
}, },
replyWindowLinkText() { replyWindowLinkText() {
if ( if (this.isAWhatsAppChannel) {
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW'); return this.$t('CONVERSATION.24_HOURS_WINDOW');
} }
if (!this.isAPIInbox) { if (!this.isAPIInbox) {
@@ -510,17 +485,11 @@ export default {
<Banner <Banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mt-2 mx-2 rounded-lg overflow-hidden"
:banner-message="replyWindowBannerMessage" :banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink" :href-link="replyWindowLink"
:href-link-text="replyWindowLinkText" :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"> <div class="flex justify-end">
<NextButton <NextButton
faded faded
@@ -261,8 +261,7 @@ export default {
this.isAnEmailChannel || this.isAnEmailChannel ||
this.isASmsInbox || this.isASmsInbox ||
this.isATelegramChannel || this.isATelegramChannel ||
this.isALineChannel || this.isALineChannel
this.isAInstagramChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -686,11 +685,7 @@ export default {
this.isATwilioWhatsAppChannel || this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel || this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel; this.is360DialogWhatsAppChannel;
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages. if (isOnWhatsApp && !this.isPrivate) {
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(this.message); this.sendMessageAsMultipleMessages(this.message);
} else { } else {
const messagePayload = this.getMessagePayload(this.message); const messagePayload = this.getMessagePayload(this.message);
@@ -707,7 +702,7 @@ export default {
} }
}, },
sendMessageAsMultipleMessages(message) { sendMessageAsMultipleMessages(message) {
const messages = this.getMultipleMessagesPayload(message); const messages = this.getMessagePayloadForWhatsapp(message);
messages.forEach(messagePayload => { messages.forEach(messagePayload => {
this.sendMessage(messagePayload); this.sendMessage(messagePayload);
}); });
@@ -939,11 +934,11 @@ export default {
return payload; return payload;
}, },
getMultipleMessagesPayload(message) { getMessagePayloadForWhatsapp(message) {
const multipleMessagePayload = []; const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles && this.attachedFiles.length) {
let caption = this.isAInstagramChannel ? '' : message; let caption = message;
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId ? attachment.blobSignedId
@@ -958,19 +953,9 @@ export default {
attachmentPayload = this.setReplyToInPayload(attachmentPayload); attachmentPayload = this.setReplyToInPayload(attachmentPayload);
multipleMessagePayload.push(attachmentPayload); multipleMessagePayload.push(attachmentPayload);
// For WhatsApp, only the first attachment gets a caption caption = '';
if (!this.isAInstagramChannel) caption = '';
}); });
} } else {
const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments
if (
(this.isAInstagramChannel && this.message) ||
(!this.isAInstagramChannel && hasNoAttachments)
) {
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message,
@@ -1091,7 +1076,7 @@ export default {
v-if="showSelfAssignBanner" v-if="showSelfAssignBanner"
action-button-variant="ghost" action-button-variant="ghost"
color-scheme="secondary" 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')" :banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')" :action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@@ -1150,7 +1135,7 @@ export default {
v-else-if="!showRichContentEditor" v-else-if="!showRichContentEditor"
ref="messageInput" ref="messageInput"
v-model="message" v-model="message"
class="rounded-none input" class="input"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:min-height="4" :min-height="4"
:signature="signatureToApply" :signature="signatureToApply"
@@ -1210,7 +1195,6 @@ export default {
:mode="replyType" :mode="replyType"
:on-file-upload="onFileUpload" :on-file-upload="onFileUpload"
:on-send="onSendReply" :on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText" :recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState" :recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel" :send-button-text="replyButtonLabel"
@@ -69,6 +69,10 @@ const onAgentSelect = index => {
v-if="items.length" v-if="items.length"
ref="tagAgentsRef" ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong" class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
:class="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
> >
<li <li
v-for="(agent, index) in items" v-for="(agent, index) in items"
@@ -1,39 +0,0 @@
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
});
});
@@ -121,10 +121,6 @@ export const useInbox = () => {
); );
}); });
const isAInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
return { return {
inbox, inbox,
isAFacebookInbox, isAFacebookInbox,
@@ -141,6 +137,5 @@ export const useInbox = () => {
isAWhatsAppCloudChannel, isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel, is360DialogWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAInstagramChannel,
}; };
}; };
@@ -7,12 +7,8 @@ import { formatTime } from '@chatwoot/utils';
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data. * @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics. * @returns {Object} An object containing utility functions for report metrics.
*/ */
export function useReportMetrics( export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
accountSummaryKey = 'getAccountSummary',
summarFetchingKey = 'getAccountSummaryFetchingStatus'
) {
const accountSummary = useMapGetter(accountSummaryKey); const accountSummary = useMapGetter(accountSummaryKey);
const fetchingStatus = useMapGetter(summarFetchingKey);
/** /**
* Calculates the trend percentage for a given metric. * Calculates the trend percentage for a given metric.
@@ -57,6 +53,5 @@ export function useReportMetrics(
calculateTrend, calculateTrend,
isAverageMetricType, isAverageMetricType,
displayMetric, displayMetric,
fetchingStatus,
}; };
} }
@@ -1,22 +0,0 @@
import { computed } from 'vue';
/**
* Composable to extract translation state/content from contentAttributes.
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
});
return { hasTranslations, translationContent };
}
@@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value; const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
// If the sidebar order is not set, use the default order. // If the sidebar order is not set, use the default order.
if (!itemsOrder) { 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. // Create a copy of itemsOrder to avoid mutating the original store object.
const itemsOrderCopy = [...itemsOrder]; const itemsOrderCopy = [...itemsOrder];
-1
View File
@@ -34,7 +34,6 @@ export const FEATURE_FLAGS = {
CUSTOM_ROLES: 'custom_roles', CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4', CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4', REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
}; };
@@ -16,15 +16,6 @@ export const getUserPermissions = (user, accountId) => {
return currentAccount.permissions || []; return currentAccount.permissions || [];
}; };
export const getUserRole = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
if (currentAccount.custom_role_id) {
return 'custom_role';
}
return currentAccount.role || 'agent';
};
const isPermissionsPresentInRoute = route => const isPermissionsPresentInRoute = route =>
route.meta && route.meta.permissions; route.meta && route.meta.permissions;
@@ -2,13 +2,23 @@
"AGENT_BOTS": { "AGENT_BOTS": {
"HEADER": "Bots", "HEADER": "Bots",
"LOADING_EDITOR": "Loading editor...", "LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.", "DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
"LEARN_MORE": "Learn about agent bots", "LEARN_MORE": "Learn about agent bots",
"GLOBAL_BOT": "System bot", "CSML_BOT_EDITOR": {
"GLOBAL_BOT_BADGE": "System", "NAME": {
"AVATAR": { "LABEL": "Bot name",
"SUCCESS_DELETE": "Bot avatar deleted successfully", "PLACEHOLDER": "Name your bot.",
"ERROR_DELETE": "Error deleting bot avatar, please try again" "ERROR": "Bot name is required."
},
"DESCRIPTION": {
"LABEL": "Bot description",
"PLACEHOLDER": "What does this bot do?"
},
"BOT_CONFIG": {
"ERROR": "Please enter your CSML bot configuration above.",
"API_ERROR": "Your CSML configuration is invalid. Please fix it and try again."
},
"SUBMIT": "Validate and save"
}, },
"BOT_CONFIGURATION": { "BOT_CONFIGURATION": {
"TITLE": "Select an agent bot", "TITLE": "Select an agent bot",
@@ -22,7 +32,7 @@
"SELECT_PLACEHOLDER": "Select bot" "SELECT_PLACEHOLDER": "Select bot"
}, },
"ADD": { "ADD": {
"TITLE": "Add Bot", "TITLE": "Configure new bot",
"CANCEL_BUTTON_TEXT": "Cancel", "CANCEL_BUTTON_TEXT": "Cancel",
"API": { "API": {
"SUCCESS_MESSAGE": "Bot added successfully.", "SUCCESS_MESSAGE": "Bot added successfully.",
@@ -30,22 +40,16 @@
} }
}, },
"LIST": { "LIST": {
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.", "404": "No bots found. You can create a bot by clicking the 'Configure new bot' button ↗",
"LOADING": "Fetching bots...", "LOADING": "Fetching bots...",
"TABLE_HEADER": { "TYPE": "Bot type"
"DETAILS": "Bot Details",
"URL": "Webhook URL"
}
}, },
"DELETE": { "DELETE": {
"BUTTON_TEXT": "Delete", "BUTTON_TEXT": "Delete",
"TITLE": "Delete bot", "TITLE": "Delete bot",
"CONFIRM": { "SUBMIT": "Delete",
"TITLE": "Confirm Deletion", "CANCEL_BUTTON_TEXT": "Cancel",
"MESSAGE": "Are you sure you want to delete {name}?", "DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible.",
"YES": "Yes, Delete",
"NO": "No, Keep"
},
"API": { "API": {
"SUCCESS_MESSAGE": "Bot deleted successfully.", "SUCCESS_MESSAGE": "Bot deleted successfully.",
"ERROR_MESSAGE": "Could not delete bot. Please try again." "ERROR_MESSAGE": "Could not delete bot. Please try again."
@@ -53,44 +57,17 @@
}, },
"EDIT": { "EDIT": {
"BUTTON_TEXT": "Edit", "BUTTON_TEXT": "Edit",
"LOADING": "Fetching bots...",
"TITLE": "Edit bot", "TITLE": "Edit bot",
"CANCEL_BUTTON_TEXT": "Cancel",
"API": { "API": {
"SUCCESS_MESSAGE": "Bot updated successfully.", "SUCCESS_MESSAGE": "Bot updated successfully.",
"ERROR_MESSAGE": "Could not update bot. Please try again." "ERROR_MESSAGE": "Could not update bot. Please try again."
} }
}, },
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
},
"NAME": {
"LABEL": "Bot name",
"PLACEHOLDER": "Enter bot name",
"REQUIRED": "Bot name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "What does this bot do?"
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "https://example.com/webhook",
"REQUIRED": "Webhook URL is required"
},
"ERRORS": {
"NAME": "Bot name is required",
"URL": "Webhook URL is required",
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
},
"CANCEL": "Cancel",
"CREATE": "Create Bot",
"UPDATE": "Update Bot"
},
"WEBHOOK": {
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
},
"TYPES": { "TYPES": {
"WEBHOOK": "Webhook bot" "WEBHOOK": "Webhook bot",
"CSML": "CSML bot"
} }
} }
} }
@@ -32,12 +32,10 @@
"LOADING_CONVERSATIONS": "Loading Conversations", "LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to", "CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction", "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?", "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", "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_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", "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:", "REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
@@ -48,12 +48,9 @@
}, },
"INSTAGRAM": { "INSTAGRAM": {
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again", "ERROR_AUTH": "There was an error connecting to Instagram, please try again"
"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."
}, },
"TWITTER": { "TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with 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", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"SEND_MESSAGE": "Send message...", "SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
"YOU": "You", "YOU": "You",
"USE": "Use this", "USE": "Use this",
@@ -1,191 +1,102 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import AgentBotRow from './components/AgentBotRow.vue';
import SettingsLayout from '../SettingsLayout.vue'; import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const MODAL_TYPES = {
CREATE: 'create',
EDIT: 'edit',
};
const router = useRouter();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const agentBots = useMapGetter('agentBots/getBots'); const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags'); const uiFlags = useMapGetter('agentBots/getUIFlags');
const selectedBot = ref({}); const confirmDialog = ref(null);
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
const agentBotDeleteDialogRef = ref(null);
const tableHeaders = computed(() => { const onConfigureNewBot = () => {
return [ router.push({
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'), name: 'agent_bots_csml_new',
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'), });
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
agentBotModalRef.value.dialogRef.open();
};
const openEditModal = bot => {
modalType.value = MODAL_TYPES.EDIT;
selectedBot.value = bot;
agentBotModalRef.value.dialogRef.open();
};
const openDeletePopup = bot => {
selectedBot.value = bot;
agentBotDeleteDialogRef.value.open();
};
const deleteAgentBot = async id => {
try {
await store.dispatch('agentBots/delete', id);
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
} finally {
loading.value[id] = false;
selectedBot.value = {};
}
};
const confirmDeletion = () => {
loading.value[selectedBot.value.id] = true;
deleteAgentBot(selectedBot.value.id);
agentBotDeleteDialogRef.value.close();
}; };
onMounted(() => { onMounted(() => {
store.dispatch('agentBots/get'); store.dispatch('agentBots/get');
}); });
const onDeleteAgentBot = async bot => {
const ok = await confirmDialog.value.showConfirmation();
if (ok) {
try {
await store.dispatch('agentBots/delete', bot.id);
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
}
}
};
const onEditAgentBot = bot => {
router.push(
frontendURL(
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
)
);
};
</script> </script>
<template> <template>
<SettingsLayout <SettingsLayout
:is-loading="uiFlags.isFetching" :is-loading="uiFlags.isFetching"
:loading-message="t('AGENT_BOTS.LIST.LOADING')" :loading-message="$t('AGENT_BOTS.LIST.LOADING')"
:no-records-found="!agentBots.length" :no-records-found="!agentBots.length"
:no-records-message="t('AGENT_BOTS.LIST.404')" :no-records-message="$t('AGENT_BOTS.LIST.404')"
> >
<template #header> <template #header>
<BaseSettingsHeader <BaseSettingsHeader
:title="t('AGENT_BOTS.HEADER')" :title="$t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')" :description="$t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')" :link-text="$t('AGENT_BOTS.LEARN_MORE')"
feature-name="agent_bots" feature-name="agent_bots"
> >
<template #actions> <template #actions>
<Button <Button
icon="i-lucide-circle-plus" icon="i-lucide-circle-plus"
:label="$t('AGENT_BOTS.ADD.TITLE')" :label="$t('AGENT_BOTS.ADD.TITLE')"
@click="openAddModal" @click="onConfigureNewBot"
/> />
</template> </template>
</BaseSettingsHeader> </BaseSettingsHeader>
</template> </template>
<template #body> <template #body>
<table class="min-w-full overflow-x-auto divide-y divide-n-strong"> <div class="flex-1 overflow-auto">
<thead> <table class="divide-y divide-slate-75 dark:divide-slate-700">
<th <tbody class="divide-y divide-n-weak text-n-slate-11">
v-for="thHeader in tableHeaders" <AgentBotRow
:key="thHeader" v-for="(agentBot, index) in agentBots"
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11" :key="agentBot.id"
> :agent-bot="agentBot"
{{ thHeader }} :index="index"
</th> @delete="onDeleteAgentBot"
</thead> @edit="onEditAgentBot"
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12"> />
<tr v-for="bot in agentBots" :key="bot.id"> </tbody>
<td class="py-4 ltr:pr-4 rtl:pl-4"> </table>
<div class="flex flex-row items-center gap-4"> <woot-confirm-modal
<Avatar ref="confirmDialog"
:name="bot.name" :title="$t('AGENT_BOTS.DELETE.TITLE')"
:src="bot.thumbnail" :description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
:size="40" />
rounded-full </div>
/>
<div>
<span class="block font-medium break-words">
{{ bot.name }}
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</span>
<span class="text-sm text-n-slate-11">
{{ bot.description }}
</span>
</div>
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template> </template>
<AgentBotModal
ref="agentBotModalRef"
:type="modalType"
:selected-bot="selectedBot"
/>
<Dialog
ref="agentBotDeleteDialogRef"
type="alert"
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
:description="
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
@confirm="confirmDeletion"
/>
</SettingsLayout> </SettingsLayout>
</template> </template>
@@ -1,6 +1,9 @@
import { FEATURE_FLAGS } from '../../../../featureFlags'; import { FEATURE_FLAGS } from '../../../../featureFlags';
import Bot from './Index.vue'; import Bot from './Index.vue';
import CsmlEditBot from './csml/Edit.vue';
import CsmlNewBot from './csml/New.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue'; import SettingsWrapper from '../SettingsWrapper.vue';
export default { export default {
@@ -23,5 +26,36 @@ export default {
}, },
], ],
}, },
{
path: frontendURL('accounts/:accountId/settings/agent-bots'),
component: SettingsContent,
props: () => {
return {
headerTitle: 'AGENT_BOTS.HEADER',
icon: 'bot',
showBackButton: true,
};
},
children: [
{
path: 'csml/new',
name: 'agent_bots_csml_new',
component: CsmlNewBot,
meta: {
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
permissions: ['administrator'],
},
},
{
path: 'csml/:botId',
name: 'agent_bots_csml_edit',
component: CsmlEditBot,
meta: {
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
permissions: ['administrator'],
},
},
],
},
], ],
}; };
@@ -1,251 +0,0 @@
<script setup>
import { ref, computed, reactive, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
selectedBot: {
type: Object,
default: () => ({}),
},
});
const MODAL_TYPES = {
CREATE: 'create',
EDIT: 'edit',
};
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const uiFlags = useMapGetter('agentBots/getUIFlags');
const formState = reactive({
botName: '',
botDescription: '',
botUrl: '',
botAvatar: null,
botAvatarUrl: '',
});
const v$ = useVuelidate(
{
botName: {
required: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
required
),
},
botUrl: {
required: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
required
),
url: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
url
),
},
},
formState
);
const isLoading = computed(() =>
props.type === MODAL_TYPES.CREATE
? uiFlags.value.isCreating
: uiFlags.value.isUpdating
);
const dialogTitle = computed(() =>
props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE')
);
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.FORM.CREATE')
: t('AGENT_BOTS.FORM.UPDATE')
);
const botNameError = computed(() =>
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
);
const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
const resetForm = () => {
Object.assign(formState, {
botName: '',
botDescription: '',
botUrl: '',
botAvatar: null,
botAvatarUrl: '',
});
v$.value.$reset();
};
const handleImageUpload = ({ file, url: avatarUrl }) => {
formState.botAvatar = file;
formState.botAvatarUrl = avatarUrl;
};
const handleAvatarDelete = async () => {
if (props.selectedBot?.id) {
try {
await store.dispatch(
'agentBots/deleteAgentBotAvatar',
props.selectedBot.id
);
formState.botAvatar = null;
formState.botAvatarUrl = '';
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
} catch (error) {
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
}
} else {
formState.botAvatar = null;
formState.botAvatarUrl = '';
}
};
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const botData = {
name: formState.botName,
description: formState.botDescription,
outgoing_url: formState.botUrl,
bot_type: 'webhook',
avatar: formState.botAvatar,
};
const isCreate = props.type === MODAL_TYPES.CREATE;
try {
const actionPayload = isCreate
? botData
: { id: props.selectedBot.id, data: botData };
await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
const alertKey = isCreate
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
dialogRef.value.close();
resetForm();
} catch (error) {
const errorKey = isCreate
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
useAlert(errorKey);
}
};
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } =
props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
} else {
resetForm();
}
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:show-cancel-button="false"
:show-confirm-button="false"
@close="v$.$reset()"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()"
/>
<NextButton
type="submit"
data-testid="label-submit"
:label="confirmButtonLabel"
:is-loading="isLoading"
:disabled="v$.$invalid"
/>
</div>
</form>
</Dialog>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import AgentBotType from './AgentBotType.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
agentBot: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
});
const emit = defineEmits(['edit', 'delete']);
const isACSMLTypeBot = computed(() => {
const { bot_type: botType } = props.agentBot;
return botType === 'csml';
});
</script>
<template>
<tr class="space-x-2">
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
<div class="flex items-center break-words font-medium">
{{ agentBot.name }}
(<AgentBotType :bot-type="agentBot.bot_type" />)
</div>
<div class="text-sm">
<ShowMore :text="agentBot.description || ''" :limit="120" />
</div>
</td>
<td class="align-middle">
<div class="flex justify-end gap-1 h-full items-center">
<Button
v-if="isACSMLTypeBot"
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', agentBot)"
/>
<Button
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="emit('delete', agentBot, index)"
/>
</div>
</td>
</tr>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
botType: {
type: String,
default: 'webhook',
},
});
const { t } = useI18n();
const botTypeConfig = computed(() => ({
csml: {
label: t('AGENT_BOTS.TYPES.CSML'),
thumbnail: '/dashboard/images/agent-bots/csml.png',
},
webhook: {
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
},
}));
</script>
<template>
<span class="inline-flex items-center gap-1">
<img
v-tooltip="botTypeConfig[botType].label"
class="h-3 w-auto"
:src="botTypeConfig[botType].thumbnail"
:alt="botTypeConfig[botType].label"
/>
<span>{{ botTypeConfig[botType].label }}</span>
</span>
</template>
@@ -0,0 +1,99 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { CsmlMonacoEditor, NextButton },
props: {
agentBot: {
type: Object,
default: () => {},
},
},
emits: ['submit'],
setup() {
return { v$: useVuelidate() };
},
validations: {
bot: {
name: { required },
csmlContent: { required },
},
},
data() {
return {
bot: {
name: this.agentBot.name || '',
description: this.agentBot.description || '',
csmlContent: this.agentBot.bot_config.csml_content || '',
},
};
},
methods: {
onSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.$emit('submit', {
id: this.agentBot.id || '',
...this.bot,
});
},
},
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-row">
<div class="w-[68%]">
<div class="h-[calc(100vh-56px)] relative">
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
<div
v-if="v$.bot.csmlContent.$error"
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
>
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
</div>
</div>
</div>
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
<form
class="flex flex-col justify-between h-full"
@submit.prevent="onSubmit"
>
<div>
<label :class="{ error: v$.bot.name.$error }">
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
<input
v-model="bot.name"
type="text"
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
/>
<span v-if="v$.bot.name.$error" class="message">
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
</span>
</label>
<label>
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
<textarea
v-model="bot.description"
rows="4"
:placeholder="
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
"
/>
</label>
<NextButton
type="submit"
:label="$t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT')"
/>
</div>
</form>
</div>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
export default {
components: { LoadingState },
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
data() {
return {
iframeLoading: true,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
window.onmessage = e => {
if (
typeof e.data !== 'string' ||
!e.data.startsWith('chatwoot-csml-editor:update')
) {
return;
}
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
this.$emit('update:modelValue', csmlContent);
};
},
methods: {
onEditorLoad() {
const frameElement = document.getElementById(`csml-editor--frame`);
const eventData = {
event: 'editorContext',
data: this.modelValue || '',
};
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
},
},
};
</script>
<template>
<div class="csml-editor--container">
<LoadingState
v-if="iframeLoading"
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
class="dashboard-app_loading-container"
/>
<iframe
id="csml-editor--frame"
:src="globalConfig.csmlEditorHost"
@load="onEditorLoad"
/>
</div>
</template>
<style scoped>
#csml-editor--frame {
border: 0;
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,40 @@
<script>
import { useAlert } from 'dashboard/composables';
import Spinner from 'shared/components/Spinner.vue';
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
export default {
components: { Spinner, CsmlBotEditor },
computed: {
agentBot() {
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
},
},
mounted() {
this.$store.dispatch('agentBots/show', this.$route.params.botId);
},
methods: {
async updateBot(bot) {
try {
await this.$store.dispatch('agentBots/update', {
id: bot.id,
name: bot.name,
description: bot.description,
bot_type: 'csml',
bot_config: { csml_content: bot.csmlContent },
});
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
}
},
},
};
</script>
<template>
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
<Spinner />
</div>
</template>
@@ -0,0 +1,41 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { frontendURL } from '../../../../../helper/URLHelper';
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
export default {
components: { CsmlBotEditor },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
methods: {
async saveBot(bot) {
try {
const agentBot = await this.$store.dispatch('agentBots/create', {
name: bot.name,
description: bot.description,
bot_type: 'csml',
bot_config: { csml_content: bot.csmlContent },
});
if (agentBot) {
this.$router.replace(
frontendURL(
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
)
);
}
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
</template>
@@ -35,7 +35,6 @@ export default {
}, },
{ key: 'telegram', name: 'Telegram' }, { key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' }, { key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
]; ];
}, },
...mapGetters({ ...mapGetters({
@@ -63,7 +62,7 @@ export default {
<template> <template>
<div <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 <PageHeader
class="max-w-4xl" class="max-w-4xl"
@@ -1,13 +1,11 @@
<script> <script>
import EmptyState from '../../../../components/widgets/EmptyState.vue'; import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.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 { export default {
components: { components: {
EmptyState, EmptyState,
NextButton, NextButton,
DuplicateInboxBanner,
}, },
computed: { computed: {
currentInbox() { currentInbox() {
@@ -18,20 +16,6 @@ export default {
isATwilioInbox() { isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms'; return this.currentInbox.channel_type === 'Channel::TwilioSms';
}, },
// Check if a facebook inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.currentInbox.instagram_id;
const facebookInbox =
this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
instagramId
);
return (
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
facebookInbox
);
},
isAEmailInbox() { isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email'; return this.currentInbox.channel_type === 'Channel::Email';
}, },
@@ -88,12 +72,8 @@ export default {
<template> <template>
<div <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 <EmptyState
:title="$t('INBOX_MGMT.FINISH.TITLE')" :title="$t('INBOX_MGMT.FINISH.TITLE')"
:message="message" :message="message"
@@ -8,7 +8,6 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue'; import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue'; import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue'; import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue'; import GoogleReauthorize from './channels/google/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue'; import PreChatFormSettings from './PreChatForm/Settings.vue';
@@ -21,7 +20,6 @@ import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags'; import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default { export default {
components: { components: {
@@ -40,7 +38,6 @@ export default {
GoogleReauthorize, GoogleReauthorize,
NextButton, NextButton,
InstagramReauthorize, InstagramReauthorize,
DuplicateInboxBanner,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@@ -141,7 +138,11 @@ export default {
} }
if ( if (
this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.AGENT_BOTS) this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.AGENT_BOTS
) &&
!(this.isAnEmailChannel || this.isATwitterInbox)
) { ) {
visibleToAllChannelTabs = [ visibleToAllChannelTabs = [
...visibleToAllChannelTabs, ...visibleToAllChannelTabs,
@@ -206,16 +207,6 @@ export default {
instagramUnauthorized() { instagramUnauthorized() {
return this.isAInstagramChannel && this.inbox.reauthorization_required; return this.isAInstagramChannel && this.inbox.reauthorization_required;
}, },
// Check if a instagram inbox exists 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;
},
microsoftUnauthorized() { microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required; return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
}, },
@@ -402,11 +393,6 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" /> <FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" /> <GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :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"> <div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection <SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
@@ -67,7 +67,7 @@ export default {
<div <div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0" class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
> >
<div class="flex flex-col items-center justify-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"> <div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5> <h5>{{ errorStateMessage }}</h5>
<p <p
@@ -77,20 +77,23 @@ export default {
</div> </div>
<div <div
v-else 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 <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" :disabled="isRequestingAuthorization"
@click="requestAuthorization()" @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"> <span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }} {{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span> </span>
@@ -117,6 +120,9 @@ export default {
</svg> </svg>
</span> </span>
</button> </button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -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 { .formkit-actions {
@apply hidden; @apply hidden;
} }
@media (prefers-color-scheme: dark) {
.pre-chat-header-message .link {
@apply text-woot-500 underline;
}
}
</style> </style>
@@ -98,7 +98,6 @@ export default {
<BotMetrics :filters="requestPayload" /> <BotMetrics :filters="requestPayload" />
<ReportContainer <ReportContainer
account-summary-key="getBotSummary" account-summary-key="getBotSummary"
summary-fetching-key="getBotSummaryFetchingStatus"
:group-by="groupBy" :group-by="groupBy"
:report-keys="reportKeys" :report-keys="reportKeys"
/> />
@@ -19,10 +19,6 @@ export default {
type: String, type: String,
default: 'getAccountSummary', default: 'getAccountSummary',
}, },
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
reportKeys: { reportKeys: {
type: Object, type: Object,
default: () => ({ default: () => ({
@@ -152,11 +148,7 @@ export default {
:key="metric.KEY" :key="metric.KEY"
class="p-4 mb-3 rounded-md" class="p-4 mb-3 rounded-md"
> >
<ChartStats <ChartStats :metric="metric" :account-summary-key="accountSummaryKey" />
:metric="metric"
:account-summary-key="accountSummaryKey"
:summary-fetching-key="summaryFetchingKey"
/>
<div class="mt-4 h-72"> <div class="mt-4 h-72">
<woot-loading-state <woot-loading-state
v-if="accountReport.isFetching[metric.KEY]" v-if="accountReport.isFetching[metric.KEY]"
@@ -1,8 +1,5 @@
<script setup> <script setup>
import { useReportMetrics } from 'dashboard/composables/useReportMetrics'; 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({ const props = defineProps({
metric: { metric: {
@@ -13,16 +10,11 @@ const props = defineProps({
type: String, type: String,
default: 'getAccountSummary', default: 'getAccountSummary',
}, },
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
}); });
const { t } = useI18n(); const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics(
props.accountSummaryKey
const { calculateTrend, displayMetric, isAverageMetricType, fetchingStatus } = );
useReportMetrics(props.accountSummaryKey, props.summaryFetchingKey);
const trendColor = (value, key) => { const trendColor = (value, key) => {
if (isAverageMetricType(key)) { if (isAverageMetricType(key)) {
@@ -42,25 +34,10 @@ const trendColor = (value, key) => {
{{ metric.NAME }} {{ metric.NAME }}
</span> </span>
<div class="flex items-end text-n-slate-12"> <div class="flex items-end text-n-slate-12">
<div v-if="fetchingStatus === STATUS.FETCHING"> <div class="text-xl font-medium">
<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"
>
{{ displayMetric(metric.KEY) }} {{ displayMetric(metric.KEY) }}
</div> </div>
<div <div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
v-if="metric.trend && fetchingStatus === STATUS.FINISHED"
class="text-xs ml-4 flex items-center mb-0.5"
>
<div <div
v-if="metric.trend < 0" v-if="metric.trend < 0"
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1" 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',
};
@@ -12,7 +12,6 @@ export const state = {
isCreating: false, isCreating: false,
isDeleting: false, isDeleting: false,
isUpdating: false, isUpdating: false,
isUpdatingAvatar: false,
isFetchingAgentBot: false, isFetchingAgentBot: false,
isSettingAgentBot: false, isSettingAgentBot: false,
isDisconnecting: false, isDisconnecting: false,
@@ -49,23 +48,10 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
} }
}, },
create: async ({ commit }, agentBotObj) => {
create: async ({ commit }, botData) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }); commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
try { try {
// Create FormData for file upload const response = await AgentBotsAPI.create(agentBotObj);
const formData = new FormData();
formData.append('name', botData.name);
formData.append('description', botData.description);
formData.append('bot_type', botData.bot_type || 'webhook');
formData.append('outgoing_url', botData.outgoing_url);
// Add avatar file if available
if (botData.avatar) {
formData.append('avatar', botData.avatar);
}
const response = await AgentBotsAPI.create(formData);
commit(types.ADD_AGENT_BOT, response.data); commit(types.ADD_AGENT_BOT, response.data);
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -75,22 +61,10 @@ export const actions = {
} }
return null; return null;
}, },
update: async ({ commit }, { id, ...agentBotObj }) => {
update: async ({ commit }, { id, data }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }); commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
try { try {
// Create FormData for file upload const response = await AgentBotsAPI.update(id, agentBotObj);
const formData = new FormData();
formData.append('name', data.name);
formData.append('description', data.description);
formData.append('bot_type', data.bot_type || 'webhook');
formData.append('outgoing_url', data.outgoing_url);
if (data.avatar) {
formData.append('avatar', data.avatar);
}
const response = await AgentBotsAPI.update(id, formData);
commit(types.EDIT_AGENT_BOT, response.data); commit(types.EDIT_AGENT_BOT, response.data);
} catch (error) { } catch (error) {
throwErrorMessage(error); throwErrorMessage(error);
@@ -98,7 +72,6 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
} }
}, },
delete: async ({ commit }, id) => { delete: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true }); commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
try { try {
@@ -110,20 +83,6 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
} }
}, },
deleteAgentBotAvatar: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
try {
await AgentBotsAPI.deleteAgentBotAvatar(id);
// Update the thumbnail to empty string after deletion
commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
}
},
show: async ({ commit }, id) => { show: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true }); commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
try { try {
@@ -191,12 +150,6 @@ export const mutations = {
[inboxId]: agentBotId, [inboxId]: agentBotId,
}; };
}, },
[types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
const botIndex = $state.records.findIndex(bot => bot.id === id);
if (botIndex !== -1) {
$state.records[botIndex].thumbnail = thumbnail || '';
}
},
}; };
export default { export default {
@@ -1,11 +1,7 @@
import { MESSAGE_TYPE } from 'shared/constants/messages'; 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 filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import { matchesFilters } from './helpers/filterHelpers'; import { matchesFilters } from './helpers/filterHelpers';
import {
getUserPermissions,
getUserRole,
} from '../../../helper/permissionsHelper';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({ export const getSelectedChatConversation = ({
@@ -81,24 +77,10 @@ const getters = {
return isUnAssigned && shouldFilter; return isUnAssigned && shouldFilter;
}); });
}, },
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => { getAllStatusChats: _state => activeFilters => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return _state.allConversations.filter(conversation => { return _state.allConversations.filter(conversation => {
const shouldFilter = applyPageFilters(conversation, activeFilters); const shouldFilter = applyPageFilters(conversation, activeFilters);
const allowedForRole = applyRoleFilter( return shouldFilter;
conversation,
userRole,
permissions,
currentUserId
);
return shouldFilter && allowedForRole;
}); });
}, },
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus, getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
@@ -62,51 +62,6 @@ export const applyPageFilters = (conversation, filters) => {
return shouldFilter; return shouldFilter;
}; };
/**
* Filters conversations based on user role and permissions
*
* @param {Object} conversation - The conversation object to check permissions for
* @param {string} role - The user's role (administrator, agent, etc.)
* @param {Array<string>} permissions - List of permission strings the user has
* @param {number|string} currentUserId - The ID of the current user
* @returns {boolean} - Whether the user has permissions to access this conversation
*/
export const applyRoleFilter = (
conversation,
role,
permissions,
currentUserId
) => {
// the role === "agent" check is typically not correct on it's own
// the backend handles this by checking the custom_role_id at the user model
// here however, the `getUserRole` returns "custom_role" if the id is present,
// so we can check the role === "agent" directly
if (['administrator', 'agent'].includes(role)) {
return true;
}
// Check for full conversation management permission
if (permissions.includes('conversation_manage')) {
return true;
}
const conversationAssignee = conversation.meta.assignee;
const isUnassigned = !conversationAssignee;
const isAssignedToUser = conversationAssignee?.id === currentUserId;
// Check unassigned management permission
if (permissions.includes('conversation_unassigned_manage')) {
return isUnassigned || isAssignedToUser;
}
// Check participating conversation management permission
if (permissions.includes('conversation_participating_manage')) {
return isAssignedToUser;
}
return false;
};
const SORT_OPTIONS = { const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'], last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'], last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
@@ -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,20 +122,6 @@ export const getters = {
item => item.channel_type !== INBOX_TYPES.EMAIL item => item.channel_type !== INBOX_TYPES.EMAIL
); );
}, },
getFacebookInboxByInstagramId: $state => instagramId => {
return $state.records.find(
item =>
item.instagram_id === instagramId &&
item.channel_type === INBOX_TYPES.FB
);
},
getInstagramInboxByInstagramId: $state => instagramId => {
return $state.records.find(
item =>
item.instagram_id === instagramId &&
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
}; };
const sendAnalyticsEvent = channelType => { const sendAnalyticsEvent = channelType => {
@@ -1,6 +1,5 @@
/* eslint no-console: 0 */ /* eslint no-console: 0 */
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import { STATUS } from '../constants';
import Report from '../../api/reports'; import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper'; import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper';
@@ -10,8 +9,6 @@ import liveReports from '../../api/liveReports';
const state = { const state = {
fetchingStatus: false, fetchingStatus: false,
accountSummaryFetchingStatus: STATUS.FINISHED,
botSummaryFetchingStatus: STATUS.FINISHED,
accountReport: { accountReport: {
isFetching: { isFetching: {
conversations_count: false, conversations_count: false,
@@ -77,12 +74,6 @@ const getters = {
getBotSummary(_state) { getBotSummary(_state) {
return _state.botSummary; return _state.botSummary;
}, },
getAccountSummaryFetchingStatus(_state) {
return _state.accountSummaryFetchingStatus;
},
getBotSummaryFetchingStatus(_state) {
return _state.botSummaryFetchingStatus;
},
getAccountConversationMetric(_state) { getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric; return _state.overview.accountConversationMetric;
}, },
@@ -131,7 +122,6 @@ export const actions = {
}); });
}, },
fetchAccountSummary({ commit }, reportObj) { fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary( Report.getSummary(
reportObj.from, reportObj.from,
reportObj.to, reportObj.to,
@@ -142,14 +132,12 @@ export const actions = {
) )
.then(accountSummary => { .then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data); commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
}) })
.catch(() => { .catch(() => {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED); commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
}); });
}, },
fetchBotSummary({ commit }, reportObj) { fetchBotSummary({ commit }, reportObj) {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getBotSummary({ Report.getBotSummary({
from: reportObj.from, from: reportObj.from,
to: reportObj.to, to: reportObj.to,
@@ -158,10 +146,9 @@ export const actions = {
}) })
.then(botSummary => { .then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data); commit(types.default.SET_BOT_SUMMARY, botSummary.data);
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
}) })
.catch(() => { .catch(() => {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED); commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
}); });
}, },
fetchAccountConversationMetric({ commit }, params = {}) { fetchAccountConversationMetric({ commit }, params = {}) {
@@ -290,12 +277,6 @@ const mutations = {
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) { [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[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) { [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
}, },
@@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../agentBots'; import { actions } from '../../agentBots';
import types from '../../../mutation-types'; import types from '../../../mutation-types';
import { agentBotRecords, agentBotData } from './fixtures'; import { agentBotRecords } from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
global.axios = axios; global.axios = axios;
@@ -30,22 +30,16 @@ describe('#actions', () => {
describe('#create', () => { describe('#create', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: agentBotRecords[0] }); axios.post.mockResolvedValue({ data: agentBotRecords[0] });
await actions.create({ commit }, agentBotData); await actions.create({ commit }, agentBotRecords[0]);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }], [types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.ADD_AGENT_BOT, agentBotRecords[0]], [types.ADD_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }], [types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
]); ]);
expect(axios.post.mock.calls.length).toBe(1);
const formDataArg = axios.post.mock.calls[0][1];
expect(formDataArg instanceof FormData).toBe(true);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' }); axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit }, {})).rejects.toThrow(Error); await expect(actions.create({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }], [types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }], [types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
@@ -56,29 +50,17 @@ describe('#actions', () => {
describe('#update', () => { describe('#update', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: agentBotRecords[0] }); axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
await actions.update( await actions.update({ commit }, agentBotRecords[0]);
{ commit },
{
id: agentBotRecords[0].id,
data: agentBotData,
}
);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }], [types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
[types.EDIT_AGENT_BOT, agentBotRecords[0]], [types.EDIT_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }], [types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
]); ]);
expect(axios.patch.mock.calls.length).toBe(1);
const formDataArg = axios.patch.mock.calls[0][1];
expect(formDataArg instanceof FormData).toBe(true);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' }); axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect( await expect(
actions.update({ commit }, { id: 1, data: {} }) actions.update({ commit }, agentBotRecords[0])
).rejects.toThrow(Error); ).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }], [types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
@@ -86,6 +68,7 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#delete', () => { describe('#delete', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: agentBotRecords[0] }); axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
@@ -1,35 +1,15 @@
export const agentBotRecords = [ export const agentBotRecords = [
{ {
account_id: 1,
id: 11, id: 11,
name: 'Agent Bot 11', name: 'Agent Bot 11',
description: 'Agent Bot Description', description: 'Agent Bot Description',
bot_type: 'webhook', type: 'csml',
thumbnail: 'https://example.com/thumbnail.jpg',
bot_config: {},
outgoing_url: 'https://example.com/outgoing',
access_token: 'hN8QwG769RqBXmme',
system_bot: false,
}, },
{ {
account_id: 1,
id: 12, id: 12,
name: 'Agent Bot 12', name: 'Agent Bot 12',
description: 'Agent Bot Description 12', description: 'Agent Bot Description 12',
bot_type: 'webhook', type: 'csml',
thumbnail: 'https://example.com/thumbnail.jpg',
bot_config: {},
outgoing_url: 'https://example.com/outgoing',
access_token: 'hN8QwG769RqBXmme',
system_bot: false,
}, },
]; ];
export const agentBotData = {
name: 'Test Bot',
description: 'Test Description',
outgoing_url: 'https://test.com',
bot_type: 'webhook',
avatar: new File([''], 'filename'),
};
@@ -51,16 +51,4 @@ describe('#mutations', () => {
expect(state.agentBotInbox).toEqual({ 3: 2 }); expect(state.agentBotInbox).toEqual({ 3: 2 });
}); });
}); });
describe('#UPDATE_AGENT_BOT_AVATAR', () => {
it('update agent bot avatar', () => {
const state = { records: [agentBotRecords[0]] };
mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
id: 11,
thumbnail: 'https://example.com/thumbnail.jpg',
});
expect(state.records[0].thumbnail).toEqual(
'https://example.com/thumbnail.jpg'
);
});
});
}); });
@@ -9,7 +9,6 @@ export default [
widget_color: null, widget_color: null,
website_token: null, website_token: null,
enable_auto_assignment: true, enable_auto_assignment: true,
instagram_id: 123456789,
}, },
{ {
id: 2, id: 2,
@@ -63,12 +62,4 @@ export default [
channel_type: 'Channel::Sms', channel_type: 'Channel::Sms',
provider: 'default', 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', () => { it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList }; const state = { records: inboxList };
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7); expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
}); });
it('getInbox', () => { it('getInbox', () => {
@@ -43,7 +43,6 @@ describe('#getters', () => {
widget_color: null, widget_color: null,
website_token: null, website_token: null,
enable_auto_assignment: true, enable_auto_assignment: true,
instagram_id: 123456789,
}); });
}); });
@@ -65,32 +64,4 @@ describe('#getters', () => {
isDeleting: false, isDeleting: false,
}); });
}); });
it('getFacebookInboxByInstagramId', () => {
const state = { records: inboxList };
expect(getters.getFacebookInboxByInstagramId(state)(123456789)).toEqual({
id: 1,
channel_id: 1,
name: 'Test FacebookPage 1',
channel_type: 'Channel::FacebookPage',
avatar_url: 'random_image.png',
page_id: '12345',
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
});
});
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 axios from 'axios';
import { actions } from '../../reports'; import { actions } from '../../reports';
import * as types from '../../../mutation-types';
import { STATUS } from '../../../constants';
import * as DownloadHelper from 'dashboard/helper/downloadHelper'; import * as DownloadHelper from 'dashboard/helper/downloadHelper';
import { flushPromises } from '@vue/test-utils';
global.open = vi.fn(); global.open = vi.fn();
global.axios = axios; global.axios = axios;
global.URL.createObjectURL = vi.fn();
vi.mock('axios'); vi.mock('axios');
vi.spyOn(DownloadHelper, 'downloadCsvFile'); vi.spyOn(DownloadHelper, 'downloadCsvFile');
describe('#actions', () => { 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', () => { describe('#downloadAgentReports', () => {
it('open CSV download prompt if API is success', async () => { 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) const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
@@ -128,9 +20,7 @@ describe('#actions', () => {
to: 1630504922510, to: 1630504922510,
fileName: 'agent-report-01-09-2021.csv', fileName: 'agent-report-01-09-2021.csv',
}; };
actions.downloadAgentReports(1, param); await actions.downloadAgentReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -149,9 +39,7 @@ describe('#actions', () => {
type: 'label', type: 'label',
fileName: 'label-report-01-09-2021.csv', fileName: 'label-report-01-09-2021.csv',
}; };
actions.downloadLabelReports(1, param); await actions.downloadLabelReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -171,9 +59,7 @@ describe('#actions', () => {
to: 1635013800, to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv', fileName: 'inbox-report-24-10-2021.csv',
}; };
actions.downloadInboxReports(1, param); await actions.downloadInboxReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -192,9 +78,7 @@ describe('#actions', () => {
to: 1635013800, to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv', fileName: 'inbox-report-24-10-2021.csv',
}; };
actions.downloadInboxReports(1, param); await actions.downloadInboxReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -189,8 +189,6 @@ export default {
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY', SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY', SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING', 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', SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING: TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING', 'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
@@ -301,7 +299,6 @@ export default {
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT', EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT', DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX', SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
// MACROS // MACROS
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG', SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
-2
View File
@@ -3,6 +3,4 @@ export const REPLY_POLICY = {
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/', 'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
TWILIO_WHATSAPP: TWILIO_WHATSAPP:
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session', 'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
}; };
@@ -57,10 +57,6 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message // https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4'; export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
export const CSAT_RATINGS = [ export const CSAT_RATINGS = [
{ {
key: 'disappointed', key: 'disappointed',
@@ -5,6 +5,7 @@ const {
AZURE_APP_ID: azureAppId, AZURE_APP_ID: azureAppId,
BRAND_NAME: brandName, BRAND_NAME: brandName,
CHATWOOT_INBOX_TOKEN: chatwootInboxToken, CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
CSML_EDITOR_HOST: csmlEditorHost,
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard, CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
DIRECT_UPLOADS_ENABLED: directUploadsEnabled, DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
DISPLAY_MANIFEST: displayManifest, DISPLAY_MANIFEST: displayManifest,
@@ -28,6 +29,7 @@ const state = {
azureAppId, azureAppId,
brandName, brandName,
chatwootInboxToken, chatwootInboxToken,
csmlEditorHost,
deploymentEnv, deploymentEnv,
createNewAccountFromDashboard, createNewAccountFromDashboard,
directUploadsEnabled: directUploadsEnabled === 'true', directUploadsEnabled: directUploadsEnabled === 'true',
@@ -264,7 +264,7 @@ export default {
<div <div
v-if="shouldShowHeaderMessage" v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)" v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-base leading-5 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. <!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
If we just pass the options as is even with null or undefined or false, If we just pass the options as is even with null or undefined or false,
@@ -354,4 +354,12 @@ export default {
.formkit-messages { .formkit-messages {
@apply list-none m-0 p-0; @apply list-none m-0 p-0;
} }
@media (prefers-color-scheme: dark) {
.pre-chat-header-message {
.link {
@apply text-woot-500 underline;
}
}
}
</style> </style>
+10
View File
@@ -0,0 +1,10 @@
class AgentBots::CsmlJob < ApplicationJob
queue_as :high
def perform(event, agent_bot, message)
event_data = { message: message }
Integrations::Csml::ProcessorService.new(
event_name: event, agent_bot: agent_bot, event_data: event_data
).perform
end
end
+2 -3
View File
@@ -12,8 +12,7 @@ class SendReplyJob < ApplicationJob
'Channel::Line' => ::Line::SendOnLineService, 'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService, 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService, 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService, 'Channel::Sms' => ::Sms::SendOnSmsService
'Channel::Instagram' => ::Instagram::SendOnInstagramService
} }
case channel_name case channel_name
@@ -28,7 +27,7 @@ class SendReplyJob < ApplicationJob
def send_on_facebook_page(message) def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message' if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform ::Instagram::SendOnInstagramService.new(message: message).perform
else else
::Facebook::SendOnFacebookService.new(message: message).perform ::Facebook::SendOnFacebookService.new(message: message).perform
end end
+13 -153
View File
@@ -2,6 +2,10 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default queue_as :default
retry_on LockAcquisitionError, wait: 1.second, attempts: 8 retry_on LockAcquisitionError, wait: 1.second, attempts: 8
include HTTParty
base_uri 'https://graph.facebook.com/v11.0/me'
# @return [Array] We will support further events like reaction or seen in future # @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze SUPPORTED_EVENTS = [:message, :read].freeze
@@ -14,64 +18,17 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end end
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) def process_entries(entries)
entries.each do |entry| entries.each do |entry|
process_single_entry(entry.with_indifferent_access) entry = entry.with_indifferent_access
end messages(entry).each do |messaging|
end send(@event_name, messaging) if event_name(messaging)
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)
end end
end end
end end
def agent_message_via_echo?(messaging) private
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
def ig_account_id def ig_account_id
@entries&.first&.dig(:id) @entries&.first&.dig(:id)
@@ -81,116 +38,19 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.dig(0, :messaging, 0, :sender, :id) @entries&.dig(0, :messaging, 0, :sender, :id)
end end
def find_channel(instagram_id)
# There will be chances for the instagram account to be connected to a facebook page,
# so we need to check for both instagram and facebook page channels
# priority is for instagram channel which created via instagram login
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
# If not found, fallback to the facebook page channel
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
channel
end
def event_name(messaging) def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) } @event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end end
def message(messaging, channel) def message(messaging)
if channel.is_a?(Channel::Instagram) ::Instagram::MessageText.new(messaging).perform
::Instagram::MessageText.new(messaging, channel).perform
else
::Instagram::Messenger::MessageText.new(messaging, channel).perform
end
end end
def read(messaging, channel) def read(messaging)
# Use a single service to handle read status for both channel types since the params are same ::Instagram::ReadStatusService.new(params: messaging).perform
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
end end
def messages(entry) def messages(entry)
(entry[:messaging].presence || entry[:standby] || []) (entry[:messaging].presence || entry[:standby] || [])
end end
end end
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
# [
# {
# "time": <timestamp>,
# "id": <INSTAGRAM_USER_ID>,
# "messaging": [
# {
# "sender": {
# "id": <INSTAGRAM_USER_ID>
# },
# "recipient": {
# "id": <INSTAGRAM_USER_ID>
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": <MESSAGE_ID>,
# "text": <MESSAGE_TEXT>
# }
# }
# ]
# }
# ]
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
# When testing in Facebook's developer dashboard, you'll get a Page-style
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
# arrive in the familiar Messenger format with a "messaging" array.
# This apparent inconsistency is actually by design - Instagram's webhooks
# use different formats for testing vs production to maintain compatibility
# with both Instagram Direct and Facebook Page integrations.
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
# Test response from via Instagram direct
# [
# {
# "id": "0",
# "time": <timestamp>,
# "changes": [
# {
# "field": "messages",
# "value": {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": "1527459824",
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# }
# ]
# }
# ]
# Test response via Facebook page
# [
# {
# "time": <timestamp>,,
# "id": "0",
# "messaging": [
# {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# ]
# }
# ]
+12 -4
View File
@@ -59,10 +59,14 @@ class AgentBotListener < BaseListener
true true
end end
def process_message_event(method_name, agent_bot, message, _event) def process_message_event(method_name, agent_bot, message, event)
# Only webhook bots are supported case agent_bot.bot_type
payload = message.webhook_data.merge(event: method_name) when 'webhook'
process_webhook_bot_event(agent_bot, payload) payload = message.webhook_data.merge(event: method_name)
process_webhook_bot_event(agent_bot, payload)
when 'csml'
process_csml_bot_event(event.name, agent_bot, message)
end
end end
def process_webhook_bot_event(agent_bot, payload) def process_webhook_bot_event(agent_bot, payload)
@@ -70,4 +74,8 @@ class AgentBotListener < BaseListener
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload) AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
end end
def process_csml_bot_event(event, agent_bot, message)
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
end
end end
-4
View File
@@ -1,8 +1,4 @@
class ConversationReplyMailer < ApplicationMailer 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 include ConversationReplyMailerHelper
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>') default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
layout :choose_layout layout :choose_layout
@@ -17,44 +17,11 @@ module ConversationReplyMailerHelper
google_smtp_settings google_smtp_settings
set_delivery_method set_delivery_method
# Email type detection logic: Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
# - 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?
mail(@options) mail(@options)
end 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 private
def google_smtp_settings def google_smtp_settings
@@ -85,8 +52,6 @@ module ConversationReplyMailerHelper
tls: false, tls: false,
enable_starttls_auto: true, enable_starttls_auto: true,
openssl_verify_mode: 'none', openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2' authentication: 'xoauth2'
} }
end end

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