Merge branch 'develop' into fix/show-error-on-clipboard-errors

This commit is contained in:
Shivam Mishra
2024-07-01 13:52:18 +05:30
committed by GitHub
31 changed files with 514 additions and 136 deletions
+2
View File
@@ -85,6 +85,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
+3 -3
View File
@@ -111,9 +111,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-rails', '>= 5.18.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
gem 'sentry-sidekiq', '>= 5.18.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.4'
@@ -228,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.0.3'
gem 'rspec-rails', '>= 6.1.3'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+19 -19
View File
@@ -150,7 +150,7 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -223,7 +223,7 @@ GEM
http (>= 3.0)
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.12.0)
erubi (1.13.0)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
@@ -460,7 +460,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.23.1)
minitest (5.24.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -489,14 +489,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.5)
nokogiri (1.16.6)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.5-arm64-darwin)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -603,7 +603,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.1)
redis-client (0.22.2)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -631,13 +631,13 @@ GEM
strscan (>= 3.0.9)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.2)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@@ -703,14 +703,14 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.17.3)
sentry-rails (5.18.0)
railties (>= 5.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
sentry-ruby (~> 5.18.0)
sentry-ruby (5.18.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sentry-sidekiq (5.18.0)
sentry-ruby (~> 5.18.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -819,7 +819,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.15)
zeitwerk (2.6.16)
PLATFORMS
arm64-darwin-20
@@ -922,7 +922,7 @@ DEPENDENCIES
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails (>= 6.0.3)
rspec-rails (>= 6.1.3)
rspec_junit_formatter
rubocop
rubocop-performance
@@ -931,9 +931,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.14.0)
sentry-rails (>= 5.18.0)
sentry-ruby
sentry-sidekiq (>= 5.15.0)
sentry-sidekiq (>= 5.18.0)
shoulda-matchers
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
+11 -1
View File
@@ -37,7 +37,7 @@ class DashboardController < ActionController::Base
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
@dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
@@ -75,4 +75,14 @@ class DashboardController < ActionController::Base
'application'
end
end
def sensitive_path?
# dont load dashboard scripts on sensitive paths like password reset
sensitive_paths = [edit_user_password_path].freeze
# remove app prefix
current_path = request.path.gsub(%r{^/app}, '')
sensitive_paths.include?(current_path)
end
end
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
%w[ENABLE_ACCOUNT_SIGNUP]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
end
end
@@ -257,7 +257,16 @@ export default {
html_content: { full: fullHTMLContent } = {},
text_content: { full: fullTextContent } = {},
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
if (fullHTMLContent) {
return fullHTMLContent;
}
if (fullTextContent) {
return fullTextContent.replace(/\n/g, '<br>');
}
return '';
},
displayQuotedButton() {
if (this.emailMessageContent.includes('<blockquote')) {
@@ -121,7 +121,7 @@ export const generateConditionOptions = (options, key = 'id') => {
};
// Add the "None" option to the agent list
export const agentList = agents => [
export const addNoneToList = agents => [
{
id: 'nil',
name: 'None',
@@ -137,8 +137,8 @@ export const getActionOptions = ({
type,
}) => {
const actionsMap = {
assign_agent: agentList(agents),
assign_team: teams,
assign_agent: addNoneToList(agents),
assign_team: addNoneToList(teams),
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
+8 -4
View File
@@ -6,12 +6,16 @@ import { validateRouteAccess } from '../helpers/RouteHelper';
export const router = new VueRouter({ mode: 'history', routes });
const sensitiveRouteNames = ['auth_password_edit'];
export const initalizeRouter = () => {
router.beforeEach((to, _, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
if (!sensitiveRouteNames.includes(to.name)) {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
}
return validateRouteAccess(to, next, window.chatwootConfig);
});
@@ -30,7 +30,7 @@
/>
<div
v-if="hasAttachments"
class="chat-bubble has-attachment agent"
class="chat-bubble has-attachment space-y-2 agent"
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
>
<div
@@ -44,6 +44,14 @@
:readable-time="readableTime"
@error="onImageLoadError"
/>
<video-bubble
v-if="attachment.file_type === 'video' && !hasVideoError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onVideoLoadError"
/>
<audio v-else-if="attachment.file_type === 'audio'" controls>
<source :src="attachment.data_url" />
</audio>
@@ -84,6 +92,7 @@ import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
import timeMixin from 'dashboard/mixins/time';
import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
@@ -100,6 +109,7 @@ export default {
components: {
AgentMessageBubble,
ImageBubble,
VideoBubble,
Thumbnail,
UserMessage,
FileBubble,
@@ -120,6 +130,7 @@ export default {
data() {
return {
hasImageError: false,
hasVideoError: false,
};
},
computed: {
@@ -215,15 +226,20 @@ export default {
watch: {
message() {
this.hasImageError = false;
this.hasVideoError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasVideoError = false;
},
methods: {
onImageLoadError() {
this.hasImageError = true;
},
onVideoLoadError() {
this.hasVideoError = true;
},
toggleReply() {
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
},
@@ -39,6 +39,14 @@
:readable-time="readableTime"
@error="onImageLoadError"
/>
<video-bubble
v-if="attachment.file_type === 'video' && !hasVideoError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onVideoLoadError"
/>
<file-bubble
v-else
:url="attachment.data_url"
@@ -72,6 +80,7 @@
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import timeMixin from 'dashboard/mixins/time';
@@ -88,6 +97,7 @@ export default {
UserMessageBubble,
MessageReplyButton,
ImageBubble,
VideoBubble,
FileBubble,
FluentIcon,
ReplyToChip,
@@ -107,6 +117,7 @@ export default {
data() {
return {
hasImageError: false,
hasVideoError: false,
};
},
computed: {
@@ -143,10 +154,12 @@ export default {
watch: {
message() {
this.hasImageError = false;
this.hasVideoError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasVideoError = false;
},
methods: {
async retrySendMessage() {
@@ -158,6 +171,9 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
onVideoLoadError() {
this.hasVideoError = true;
},
toggleReply() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
},
@@ -0,0 +1,27 @@
<script setup>
defineProps({
url: { type: String, default: '' },
readableTime: { type: String, default: '' },
});
const emits = defineEmits(['error']);
const onVideoError = () => {
emits('error');
};
</script>
<template>
<div class="block relative max-w-full">
<video
class="w-full max-w-[250px] h-auto"
:src="url"
controls
@error="onVideoError"
/>
<span
class="text-xs absolute text-white dark:text-white right-3 bottom-1 whitespace-nowrap"
>
{{ readableTime }}
</span>
</div>
</template>
+4
View File
@@ -49,6 +49,10 @@ class AccountUser < ApplicationRecord
::Agents::DestroyJob.perform_later(account, user)
end
def permissions
administrator? ? ['administrator'] : ['agent']
end
def push_event_data
{
id: id,
@@ -48,7 +48,6 @@ class JsonSchemaValidator < ActiveModel::Validator
# Add validation errors to the record with a formatted statement
validation_errors.each do |error|
# byebug
format_and_append_error(error, record)
end
end
+5 -1
View File
@@ -50,7 +50,11 @@ class ActionService
end
def assign_team(team_ids = [])
return unassign_team if team_ids[0]&.zero?
# FIXME: The explicit checks for zero or nil (string) is bad. Move
# this to a separate unassign action.
should_unassign = team_ids.blank? || %w[nil 0].include?(team_ids[0].to_s)
return @conversation.update!(team_id: nil) if should_unassign
# check if team belongs to account only if team_id is present
# if team_id is nil, then it means that the team is being unassigned
return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids)
+40
View File
@@ -0,0 +1,40 @@
class Notification::FcmService
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging'].freeze
def initialize(project_id, credentials)
@project_id = project_id
@credentials = credentials
@token_info = nil
end
def fcm_client
FCM.new(current_token, credentials_path, @project_id)
end
private
def current_token
@token_info = generate_token if @token_info.nil? || token_expired?
@token_info[:token]
end
def token_expired?
Time.zone.now >= @token_info[:expires_at]
end
def generate_token
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
json_key_io: credentials_path,
scope: SCOPES
)
token = authorizer.fetch_access_token!
{
token: token['access_token'],
expires_at: Time.zone.now + token['expires_in'].to_i
}
end
def credentials_path
StringIO.new(@credentials)
end
end
@@ -42,14 +42,12 @@ class Notification::PushNotificationService
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id)
end
def send_browser_push?(subscription)
def can_send_browser_push?(subscription)
VapidService.public_key && subscription.browser_push?
end
def send_browser_push(subscription)
return unless send_browser_push?(subscription)
WebPush.payload_send(
def browser_push_payload(subscription)
{
message: JSON.generate(push_message),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
@@ -62,44 +60,104 @@ class Notification::PushNotificationService
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
)
rescue WebPush::ExpiredSubscription
}
end
def send_browser_push(subscription)
return unless can_send_browser_push?(subscription)
WebPush.payload_send(**browser_push_payload(subscription))
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
Rails.logger.info "WebPush subscription expired: #{e.message}"
subscription.destroy!
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "WebPush operation error: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
true
end
def send_fcm_push(subscription)
return unless ENV['FCM_SERVER_KEY']
return unless firebase_credentials_present?
return unless subscription.fcm?
fcm = FCM.new(ENV.fetch('FCM_SERVER_KEY', nil))
response = fcm.send([subscription.subscription_attributes['push_token']], fcm_options)
fcm_service = Notification::FcmService.new(
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
)
fcm = fcm_service.fcm_client
response = fcm.send_v1(fcm_options(subscription))
remove_subscription_if_error(subscription, response)
end
def send_push_via_chatwoot_hub(subscription)
return if ENV['FCM_SERVER_KEY']
return unless ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
return if firebase_credentials_present?
return unless chatwoot_hub_enabled?
return unless subscription.fcm?
ChatwootHub.send_browser_push([subscription.subscription_attributes['push_token']], fcm_options)
ChatwootHub.send_push(fcm_options(subscription))
end
def firebase_credentials_present?
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
end
def chatwoot_hub_enabled?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
end
def remove_subscription_if_error(subscription, response)
subscription.destroy! if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
subscription.destroy!
else
Rails.logger.info("FCM push sent to #{user.email} with title #{push_message[:title]}")
end
end
def fcm_options
def fcm_options(subscription)
{
notification: {
title: notification.push_message_title,
body: notification.push_message_body,
sound: 'default'
},
android: { priority: 'high' },
data: { notification: notification.fcm_push_data.to_json },
collapse_key: "chatwoot_#{notification.primary_actor_type.downcase}_#{notification.primary_actor_id}"
'token': subscription.subscription_attributes['push_token'],
'data': fcm_data,
'notification': fcm_notification,
'android': fcm_android_options,
'apns': fcm_apns_options,
'fcm_options': {
analytics_label: 'Label'
}
}
end
def fcm_data
{
payload: {
data: {
notification: notification.fcm_push_data
}
}.to_json
}
end
def fcm_notification
{
title: notification.push_message_title,
body: notification.push_message_body
}
end
def fcm_android_options
{
priority: 'high'
}
end
def fcm_apns_options
{
payload: {
aps: {
sound: 'default',
category: Time.zone.now.to_i.to_s
}
}
}
end
end
@@ -14,6 +14,7 @@ json.provider resource.provider
json.pubsub_token resource.pubsub_token
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
json.role resource.active_account_user&.role
json.permissions resource.active_account_user&.permissions
json.ui_settings resource.ui_settings
json.uid resource.uid
json.type resource.type
@@ -24,6 +25,7 @@ json.accounts do
json.status account_user.account.status
json.active_at account_user.active_at
json.role account_user.role
json.permissions account_user.permissions
# the actual availability user has configured
json.availability account_user.availability
# availability derived from presence
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.9.0'
version: '3.10.2'
development:
<<: *shared
+2
View File
@@ -24,6 +24,8 @@ Rails.application.configure do
smtp_settings[:openssl_verify_mode] = ENV['SMTP_OPENSSL_VERIFY_MODE'] if ENV['SMTP_OPENSSL_VERIFY_MODE'].present?
smtp_settings[:ssl] = ActiveModel::Type::Boolean.new.cast(ENV.fetch('SMTP_SSL', true)) if ENV['SMTP_SSL']
smtp_settings[:tls] = ActiveModel::Type::Boolean.new.cast(ENV.fetch('SMTP_TLS', true)) if ENV['SMTP_TLS']
smtp_settings[:open_timeout] = ENV['SMTP_OPEN_TIMEOUT'].to_i if ENV['SMTP_OPEN_TIMEOUT'].present?
smtp_settings[:read_timeout] = ENV['SMTP_READ_TIMEOUT'].to_i if ENV['SMTP_READ_TIMEOUT'].present?
config.action_mailer.delivery_method = :smtp unless Rails.env.test?
config.action_mailer.smtp_settings = smtp_settings
+13
View File
@@ -204,3 +204,16 @@
locked: false
description: 'Disable rendering profile update page for users'
## ------ End of Configs added for enterprise clients ------ ##
## ------ Configs added for FCM v1 notifications ------ ##
- name: FIREBASE_PROJECT_ID
display_title: 'Firebase Project ID'
value:
locked: false
description: 'Firebase project ID'
- name: FIREBASE_CREDENTIALS
display_title: 'Firebase Credentials'
value:
locked: false
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
+40 -40
View File
@@ -31,7 +31,7 @@ vi:
disposable_email: Chúng tôi không cho phép các email dùng một lần
invalid_email: Bạn đã nhập một email không hợp lệ
email_already_exists: "Bạn đã đăng ký một tài khoản với %{email}"
invalid_params: 'Invalid, please check the signup paramters and try again'
invalid_params: 'Không hợp lệ, vui lòng kiểm tra thông số đăng ký và thử lại'
failed: Đăng ký thât bại
data_import:
data_type:
@@ -51,7 +51,7 @@ vi:
dyte:
invalid_message_type: "Loại tin nhắn không hợp lệ. Hành động không được phép"
slack:
invalid_channel_id: "Invalid slack channel. Please try again"
invalid_channel_id: "Kênh chùng không hợp lệ. Vui lòng thử lại"
inboxes:
imap:
socket_error: Vui lòng kiểm tra kết nối mạng, địa chỉ IMAP và thử lại.
@@ -63,43 +63,43 @@ vi:
name: không nên bắt đầu hoặc kết thúc bằng các ký hiệu và không nên có kí tự < > / \ @.
custom_filters:
number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 50.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
invalid_attribute: Khóa thuộc tính không hợp lệ - [%{key}]. Chìa khóa phải là một trong [%{allowed_keys}] hoặc thuộc tính tùy chỉnh được xác định trong tài khoản.
invalid_operator: Toán tử không hợp lệ. Các toán tử được phép cho %{attribute_name} [%{allowed_keys}].
invalid_value: Giá trị không hợp lệ. Các giá trị được cung cấp cho %{attribute_name} không hợp lệ
reports:
period: Thời gian báo cáo từ %{since} đến %{until}
utc_warning: Báo cáo đã được tạo với múi giờ UTC
agent_csv:
agent_name: Tên tổng đài viên
conversations_count: Assigned conversations
avg_first_response_time: Avg first response time
conversations_count: Cuộc trò chuyện được chỉ định
avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Avg resolution time
resolution_count: Số lượng giải quyết
avg_customer_waiting_time: Avg customer waiting time
avg_customer_waiting_time: Thời gian chờ đợi trung bình của khách hàng
inbox_csv:
inbox_name: Tên kênh
inbox_type: Kiểu kênh
conversations_count: Số hội thoại
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Thời gian giải quyết trung bình
label_csv:
label_title: Nhãn
conversations_count: Số hội thoại
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Thời gian giải quyết trung bình
team_csv:
team_name: Tên nhóm
conversations_count: Số hội thoại
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Thời gian giải quyết trung bình
resolution_count: Số lượng giải quyết
avg_customer_waiting_time: Avg customer waiting time
avg_customer_waiting_time: Thời gian chờ đợi trung bình của khách hàng
conversation_traffic_csv:
timezone: Múi giờ
sla_csv:
conversation_id: Conversation ID
conversation_id: ID hội thoại
sla_policy_breached: SLA Policy
assignee: Assignee
assignee: Đại lý được chỉ định
team: Nhóm
inbox: Hộp thư đến
labels: Nhãn
@@ -118,22 +118,22 @@ vi:
recorded_at: Ngày nghi
notifications:
notification_title:
conversation_creation: "A conversation (#%{display_id}) has been created in %{inbox_name}"
conversation_assignment: "A conversation (#%{display_id}) has been assigned to you"
assigned_conversation_new_message: "A new message is created in conversation (#%{display_id})"
conversation_mention: "You have been mentioned in conversation (#%{display_id})"
sla_missed_first_response: "SLA target first response missed for conversation (#%{display_id})"
sla_missed_next_response: "SLA target next response missed for conversation (#%{display_id})"
sla_missed_resolution: "SLA target resolution missed for conversation (#%{display_id})"
attachment: "Attachment"
no_content: "No content"
conversation_creation: "Một cuộc trò chuyện (#%{display_id}) đã được tạo trong %{inbox_name}"
conversation_assignment: "Một cuộc trò chuyện (#%{display_id}) đã được chỉ định cho bạn"
assigned_conversation_new_message: "Một tin nhắn mới được tạo trong cuộc trò chuyện (#%{display_id})"
conversation_mention: "Bạn đã được nhắc đến trong cuộc trò chuyện (#%{display_id})"
sla_missed_first_response: "Mục tiêu SLA phản hồi đầu tiên bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})"
sla_missed_next_response: "Mục tiêu SLA phản hồi tiếp theo bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})"
sla_missed_resolution: "Độ phân giải mục tiêu SLA bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})"
attachment: "Tập tin đính kèm"
no_content: "Không có nội dung"
conversations:
messages:
instagram_story_content: "%{story_sender} đã đề cập đến bạn trong hội thoại: "
instagram_deleted_story_content: Hội thoại này không còn nữa.
deleted: Tin nhắn đã bị xoá
delivery_status:
error_code: "Error code: %{error_code}"
error_code: "Mã lỗi: %{error_code}"
activity:
status:
resolved: "Cuộc trò chuyện được đánh dấu là đã giải quyết bởi %{user_name}"
@@ -221,7 +221,7 @@ vi:
common:
home: Trang Chủ
last_updated_on: 'Cập nhật lần cuối: %{last_updated_on}'
view_all_articles: View all
view_all_articles: Xem tất cả
article: bài viết
articles: bài viết
author: tác giả
@@ -233,17 +233,17 @@ vi:
footer:
made_with: Tạo bởi
header:
go_to_homepage: Website
go_to_homepage: Trang web
appearance:
system: System
light: Light
dark: Dark
featured_articles: Featured Articles
system: Hệ thống
light: Sáng
dark: Tối
featured_articles: Bài viết nổi bật
uncategorized: Chưa được phân loại
404:
title: Page not found
description: We couldn't find the page you were looking for.
back_to_home: Go to home page
title: Không tìm thấy trang
description: Chúng tôi không thể tìm thấy trang bạn đang tìm kiếm.
back_to_home: Tới trang chủ
slack_unfurl:
fields:
name: Tên
@@ -255,10 +255,10 @@ vi:
button: Mở cuộc trò chuyện
time_units:
days:
other: "%{count} days"
other: "%{count} ngày"
hours:
other: "%{count} hours"
other: "%{count} giờ"
minutes:
other: "%{count} minutes"
other: "%{count} phút"
seconds:
other: "%{count} seconds"
other: "%{count} giây"
+2 -2
View File
@@ -77,8 +77,8 @@ class ChatwootHub
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_browser_push(fcm_token_list, fcm_options)
info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options }
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.9.0",
"version": "3.10.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -41,6 +41,17 @@ RSpec.describe 'Session', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(user_with_new_pwd.email)
end
it 'returns the permission of the user' do
params = { email: user.email, password: 'Password1!' }
post new_user_session_url,
params: params,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['data']['permissions']).to eq(['agent'])
end
end
context 'when it is invalid sso auth token' do
+12
View File
@@ -16,5 +16,17 @@ FactoryBot.define do
conversation.contact ||= create(:contact, :with_email, account: conversation.account)
conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox)
end
trait :with_team do
after(:build) do |conversation|
conversation.team ||= create(:team, account: conversation.account)
end
end
trait :with_assignee do
after(:build) do |conversation|
conversation.assignee ||= create(:user, account: conversation.account, role: :agent)
end
end
end
end
@@ -6,5 +6,13 @@ FactoryBot.define do
identifier { 'test' }
subscription_type { 'browser_push' }
subscription_attributes { { endpoint: 'test', auth: 'test' } }
trait :browser_push do
subscription_type { 'browser_push' }
end
trait :fcm do
subscription_type { 'fcm' }
end
end
end
+11
View File
@@ -17,6 +17,17 @@ RSpec.describe AccountUser do
end
end
describe 'permissions' do
it 'returns the right permissions' do
expect(account_user.permissions).to eq(['agent'])
end
it 'returns the right permissions for administrator' do
account_user.administrator!
expect(account_user.permissions).to eq(['administrator'])
end
end
describe 'destroy call agent::destroy service' do
it 'gets created with the right default settings' do
create(:conversation, account: account_user.account, assignee: account_user.user, inbox: inbox)
+40 -4
View File
@@ -31,18 +31,54 @@ describe ActionService do
describe '#assign_agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
let(:conversation) { create(:conversation, :with_assignee, account: account) }
let(:action_service) { described_class.new(conversation) }
it 'unassigns the conversation if agent id is nil' do
action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil
end
end
it 'unassigns the team if team_id is nil' do
action_service.assign_team([nil])
expect(conversation.reload.team).to be_nil
describe '#assign_team' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
let(:team) { create(:team, name: 'ConversationTeam', account: account) }
let(:conversation) { create(:conversation, :with_team, account: account) }
let(:action_service) { described_class.new(conversation) }
context 'when team_id is not present' do
it 'unassign the if team_id is "nil"' do
expect do
action_service.assign_team(['nil'])
end.not_to raise_error
expect(conversation.reload.team).to be_nil
end
it 'unassign the if team_id is 0' do
expect do
action_service.assign_team([0])
end.not_to raise_error
expect(conversation.reload.team).to be_nil
end
end
context 'when team_id is present' do
it 'assign the team if the team is part of the account' do
original_team = conversation.team
expect do
action_service.assign_team([team.id])
end.to change { conversation.reload.team }.from(original_team)
end
it 'does not assign the team if the team is part of the account' do
original_team = conversation.team
invalid_team_id = 999_999_999
expect do
action_service.assign_team([invalid_team_id])
end.not_to change { conversation.reload.team }.from(original_team)
end
end
end
end
@@ -0,0 +1,70 @@
require 'rails_helper'
describe Notification::FcmService do
let(:project_id) { 'test_project_id' }
let(:credentials) { '{ "type": "service_account", "project_id": "test_project_id" }' }
let(:fcm_service) { described_class.new(project_id, credentials) }
let(:fcm_double) { instance_double(FCM) }
let(:token_info) { { token: 'test_token', expires_at: 1.hour.from_now } }
let(:creds_double) do
instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'test_token', 'expires_in' => 3600 })
end
before do
allow(FCM).to receive(:new).and_return(fcm_double)
allow(fcm_service).to receive(:generate_token).and_return(token_info)
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
end
describe '#fcm_client' do
it 'returns an FCM client' do
expect(fcm_service.fcm_client).to eq(fcm_double)
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
end
it 'generates a new token if expired' do
allow(fcm_service).to receive(:generate_token).and_return(token_info)
allow(fcm_service).to receive(:token_expired?).and_return(true)
expect(fcm_service.fcm_client).to eq(fcm_double)
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
expect(fcm_service).to have_received(:generate_token)
end
end
describe 'private methods' do
describe '#current_token' do
it 'returns the current token if not expired' do
fcm_service.instance_variable_set(:@token_info, token_info)
expect(fcm_service.send(:current_token)).to eq('test_token')
end
it 'generates a new token if expired' do
expired_token_info = { token: 'expired_token', expires_at: 1.hour.ago }
fcm_service.instance_variable_set(:@token_info, expired_token_info)
allow(fcm_service).to receive(:generate_token).and_return(token_info)
expect(fcm_service.send(:current_token)).to eq('test_token')
expect(fcm_service).to have_received(:generate_token)
end
end
describe '#generate_token' do
it 'generates a new token' do
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
token = fcm_service.send(:generate_token)
expect(token[:token]).to eq('test_token')
expect(token[:expires_at]).to be_within(1.second).of(Time.zone.now + 3600)
end
end
describe '#credentials_path' do
it 'creates a StringIO with credentials' do
string_io = fcm_service.send(:credentials_path)
expect(string_io).to be_a(StringIO)
expect(string_io.read).to eq(credentials)
end
end
end
end
@@ -4,32 +4,61 @@ describe Notification::PushNotificationService do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let!(:notification) { create(:notification, user: user, account: user.accounts.first) }
let(:fcm_double) { double }
before do
allow(WebPush).to receive(:payload_send).and_return(true)
allow(FCM).to receive(:new).and_return(fcm_double)
allow(fcm_double).to receive(:send).and_return({ body: { 'results': [] }.to_json })
end
let(:fcm_double) { instance_double(FCM) }
let(:fcm_service_double) { instance_double(Notification::FcmService, fcm_client: fcm_double) }
describe '#perform' do
it 'sends webpush notifications for webpush subscription' do
with_modified_env VAPID_PUBLIC_KEY: 'test' do
create(:notification_subscription, user: notification.user)
context 'when the push server returns success' do
before do
allow(WebPush).to receive(:payload_send).and_return(true)
allow(Rails.logger).to receive(:info)
allow(Notification::FcmService).to receive(:new).and_return(fcm_service_double)
allow(fcm_double).to receive(:send_v1).and_return({ body: { 'results': [] }.to_json })
allow(GlobalConfigService).to receive(:load).with('FIREBASE_PROJECT_ID', nil).and_return('test_project_id')
allow(GlobalConfigService).to receive(:load).with('FIREBASE_CREDENTIALS', nil).and_return('test_credentials')
end
described_class.new(notification: notification).perform
expect(WebPush).to have_received(:payload_send)
expect(FCM).not_to have_received(:new)
it 'sends webpush notifications for webpush subscription' do
with_modified_env VAPID_PUBLIC_KEY: 'test' do
create(:notification_subscription, user: notification.user)
described_class.new(notification: notification).perform
expect(WebPush).to have_received(:payload_send)
expect(Notification::FcmService).not_to have_received(:new)
expect(Rails.logger).to have_received(:info).with("Browser push sent to #{user.email} with title #{notification.push_message_title}")
end
end
it 'sends a fcm notification for firebase subscription' do
with_modified_env ENABLE_PUSH_RELAY_SERVER: 'false' do
create(:notification_subscription, user: notification.user, subscription_type: 'fcm')
described_class.new(notification: notification).perform
expect(Notification::FcmService).to have_received(:new)
expect(fcm_double).to have_received(:send_v1)
expect(WebPush).not_to have_received(:payload_send)
expect(Rails.logger).to have_received(:info).with("FCM push sent to #{user.email} with title #{notification.push_message_title}")
end
end
end
end
it 'sends a fcm notification for firebase subscription' do
with_modified_env FCM_SERVER_KEY: 'test', ENABLE_PUSH_RELAY_SERVER: 'false' do
create(:notification_subscription, user: notification.user, subscription_type: 'fcm')
context 'when the push server returns error' do
it 'sends webpush notifications for webpush subscription' do
with_modified_env VAPID_PUBLIC_KEY: 'test' do
mock_response = instance_double(Net::HTTPResponse, body: 'Subscription is invalid')
mock_host = 'fcm.googleapis.com'
allow(WebPush).to receive(:payload_send).and_raise(WebPush::InvalidSubscription.new(mock_response, mock_host))
allow(Rails.logger).to receive(:info)
create(:notification_subscription, :browser_push, user: notification.user)
expect(Rails.logger).to receive(:info) do |message|
expect(message).to include('WebPush subscription expired:')
end
described_class.new(notification: notification).perform
expect(FCM).to have_received(:new)
expect(WebPush).not_to have_received(:payload_send)
end
end
end
+7 -12
View File
@@ -21315,21 +21315,16 @@ write-file-atomic@^4.0.2:
signal-exit "^3.0.7"
ws@^6.2.1:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e"
integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==
version "6.2.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee"
integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==
dependencies:
async-limiter "~1.0.0"
ws@^8.11.0:
version "8.14.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
ws@^8.2.3:
version "8.13.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
ws@^8.11.0, ws@^8.2.3:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
x-default-browser@^0.4.0:
version "0.4.0"