Merge branch 'release/3.5.0'

This commit is contained in:
Sojan
2024-01-16 11:15:11 +04:00
75 changed files with 1208 additions and 631 deletions

View File

@@ -75,8 +75,8 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage', '>= 1.0.0'
gem 'administrate-field-belongs_to_search'
gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -162,7 +162,7 @@ gem 'omniauth-oauth2'
gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth'
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
@@ -224,7 +224,7 @@ group :development, :test do
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'debug', '~> 1.8'
gem 'factory_bot_rails', '>= 6.4.2'
gem 'factory_bot_rails', '>= 6.4.3'
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'

View File

@@ -113,13 +113,13 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.0)
administrate-field-active_storage (1.0.1)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0)
administrate-field-belongs_to_search (0.9.0)
administrate (>= 0.3, < 1.0)
jbuilder (~> 2)
rails (>= 4.2, < 7.1)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@@ -230,9 +230,9 @@ GEM
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.4.2)
factory_bot (6.4.5)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.2)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
faker (3.2.0)
@@ -472,7 +472,7 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.5)
net-imap (0.4.9)
date
net-protocol
net-pop (0.1.2)
@@ -487,15 +487,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.6.0)
nokogiri (1.15.5)
nio4r (2.7.0)
nokogiri (1.16.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.5-arm64-darwin)
nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.5-x86_64-darwin)
nokogiri (1.16.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.5-x86_64-linux)
nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -511,7 +511,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
omniauth (2.1.1)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
@@ -553,7 +553,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.3.1)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -568,8 +568,8 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (3.0.6)
rack
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
@@ -841,8 +841,8 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.19.0)
administrate-field-active_storage (>= 1.0.0)
administrate-field-belongs_to_search
administrate-field-active_storage (>= 1.0.1)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -870,7 +870,7 @@ DEPENDENCIES
elastic-apm
email_reply_trimmer
facebook-messenger
factory_bot_rails (>= 6.4.2)
factory_bot_rails (>= 6.4.3)
faker
fcm
flag_shih_tzu
@@ -905,7 +905,7 @@ DEPENDENCIES
neighbor
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth
omniauth (>= 2.1.2)
omniauth-google-oauth2
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)

View File

@@ -118,4 +118,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2023, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License.

View File

@@ -48,6 +48,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@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
@@ -118,7 +122,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
{
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
@@ -129,6 +133,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
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?

View File

@@ -60,13 +60,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_status
if params[:status].present?
# FIXME: move this logic into a service object
if pending_to_open_by_bot?
@conversation.bot_handoff!
elsif params[:status].present?
set_conversation_status
@status = @conversation.save!
else
@status = @conversation.toggle_status
end
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
assign_conversation if should_assign_conversation?
end
def pending_to_open_by_bot?
return false unless Current.user.is_a?(AgentBot)
@conversation.status == 'pending' && params[:status] == 'open'
end
def should_assign_conversation?
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority

View File

@@ -14,7 +14,7 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner
Current.user = @resource if current_user.is_a?(User)
Current.user = @resource if [User, AgentBot].include?(@resource.class)
end
def validate_bot_access_token!

View File

@@ -9,6 +9,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
# rubocop:enable Style/HashTransformValues
@installation_configs = ConfigLoader.new.general_configs.each_with_object({}) do |config_hash, result|
result[config_hash['name']] = config_hash.except('name')
end
end
def create

View File

@@ -76,7 +76,11 @@
&.left {
.bubble {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50 rounded-r-lg rounded-l mr-auto break-words;
@apply rounded-r-lg rounded-l mr-auto break-words;
&:not(.is-unsupported) {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50
}
&.is-image {
@apply rounded-lg;

View File

@@ -1,24 +1,33 @@
<template>
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container">
<div class="ui-notification">
<fluent-icon icon="wifi-off" />
<p class="ui-notification-text">
{{
useInstallationName(
$t('NETWORK.NOTIFICATION.TEXT'),
globalConfig.installationName
)
}}
</p>
<woot-button variant="clear" size="small" @click="refreshPage">
{{ $t('NETWORK.BUTTON.REFRESH') }}
</woot-button>
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
>
<fluent-icon
icon="wifi-off"
class="text-yellow-700/50 dark:text-yellow-50"
size="18"
/>
<span
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
>
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
</span>
<woot-button
variant="smooth"
:title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small"
color-scheme="warning"
icon="dismiss-circle"
icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage"
/>
<woot-button
variant="clear"
size="small"
color-scheme="warning"
icon="dismiss"
@click="closeNotification"
/>
</div>
@@ -47,7 +56,12 @@ export default {
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
this.updateOnlineStatus({ type: 'offline' });
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
});
},
@@ -72,33 +86,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.ui-notification-container {
max-width: 25rem;
position: absolute;
right: var(--space-normal);
top: var(--space-normal);
z-index: var(--z-index-very-high);
}
.ui-notification {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--y-100);
border-radius: var(--border-radius-medium);
box-shadow: var(--shadow-large);
min-width: 15rem;
padding: var(--space-normal);
}
.ui-notification-text {
margin: 0 var(--space-small);
}
</style>

View File

@@ -84,7 +84,10 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -140,6 +143,15 @@ export default {
},
data() {
return {
typingIndicator: createTypingIndicator(
() => {
this.$emit('typing-on');
},
() => {
this.$emit('typing-off');
},
TYPING_INDICATOR_IDLE_TIME
),
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
@@ -638,15 +650,6 @@ export default {
hideMentions() {
this.showUserMentions = false;
},
resetTyping() {
this.$emit('typing-off');
this.idleTimer = null;
},
turnOffIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
},
handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
@@ -666,14 +669,7 @@ export default {
}
},
onKeyup() {
if (!this.idleTimer) {
this.$emit('typing-on');
}
this.turnOffIdleTimer();
this.idleTimer = setTimeout(
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
this.typingIndicator.start();
this.updateImgToolbarOnDelete();
},
onKeydown(event) {
@@ -685,8 +681,7 @@ export default {
}
},
onBlur() {
this.turnOffIdleTimer();
this.resetTyping();
this.typingIndicator.stop();
this.$emit('blur');
},
onFocus() {

View File

@@ -1,5 +1,9 @@
<template>
<mention-box :items="items" @mention-select="handleMentionClick">
<mention-box
v-if="items.length"
:items="items"
@mention-select="handleMentionClick"
>
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>

View File

@@ -29,8 +29,19 @@
:message-type="data.message_type"
:parent-has-attachments="hasAttachments"
/>
<div v-if="isUnsupported">
<template v-if="isAFacebookInbox && isInstagram">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM') }}
</template>
<template v-else-if="isAFacebookInbox">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
</template>
<template v-else>
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</template>
</div>
<bubble-text
v-if="data.content"
v-else-if="data.content"
:message="message"
:is-email="isEmailContentType"
:display-quoted-button="displayQuotedButton"
@@ -176,6 +187,14 @@ export default {
type: Boolean,
default: false,
},
isAFacebookInbox: {
type: Boolean,
default: false,
},
isInstagram: {
type: Boolean,
default: false,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
@@ -219,6 +238,7 @@ export default {
this.hasAttachments ||
this.data.content ||
this.isEmailContentType ||
this.isUnsupported ||
this.isAnIntegrationMessage
);
},
@@ -407,6 +427,7 @@ export default {
return {
bubble: this.isBubble,
'is-private': this.data.private,
'is-unsupported': this.isUnsupported,
'is-image': this.hasMediaAttachment('image'),
'is-video': this.hasMediaAttachment('video'),
'is-text': this.hasText,
@@ -415,6 +436,9 @@ export default {
'is-email': this.isEmailContentType,
};
},
isUnsupported() {
return this.contentAttributes.is_unsupported ?? false;
},
isPending() {
return this.data.status === MESSAGE_STATUS.PROGRESS;
},
@@ -426,11 +450,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending);
},
errorMessage() {
const { meta } = this.data;
return meta ? meta.error : '';
return !(this.isFailed || this.isPending || this.isUnsupported);
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -532,6 +552,14 @@ export default {
> .bubble {
@apply min-w-[128px];
&.is-unsupported {
@apply text-xs max-w-[300px] border-dashed border border-slate-200 text-slate-600 dark:text-slate-200 bg-slate-50 dark:bg-slate-700 dark:border-slate-500;
.message-text--metadata .time {
@apply text-slate-400 dark:text-slate-300;
}
}
&.is-image,
&.is-video {
@apply p-0 overflow-hidden;
@@ -544,10 +572,12 @@ export default {
> video {
@apply rounded-lg;
}
> video {
@apply h-full w-full object-cover;
}
}
.video {
@apply h-[11.25rem];
}
@@ -562,9 +592,11 @@ export default {
.file--icon {
@apply text-woot-400 dark:text-woot-400;
}
.text-block-title {
@apply text-slate-700 dark:text-slate-700;
}
.download.button {
@apply text-woot-400 dark:text-woot-400;
}
@@ -573,6 +605,7 @@ export default {
&.is-private.is-text > .message-text__wrap .link {
@apply text-woot-600 dark:text-woot-200;
}
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
@apply font-bold bg-none rounded-sm p-0 bg-yellow-100 dark:bg-yellow-700 text-slate-700 dark:text-slate-25 underline;
}
@@ -583,6 +616,7 @@ export default {
.message-text--metadata .time {
@apply text-violet-50 dark:text-violet-50;
}
&.is-private .message-text--metadata .time {
@apply text-slate-400 dark:text-slate-400;
}

View File

@@ -32,6 +32,8 @@
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
@@ -54,6 +56,8 @@
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
@@ -283,6 +287,9 @@ export default {
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =

View File

@@ -271,6 +271,11 @@ export default {
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -509,6 +514,7 @@ export default {
messageVariables() {
const variables = getMessageVariables({
conversation: this.currentChat,
contact: this.currentContact,
});
return variables;
},

View File

@@ -1,5 +1,10 @@
<template>
<mention-box :items="items" @mention-select="handleVariableClick">
<mention-box
v-if="items.length"
type="variable"
:items="items"
@mention-select="handleVariableClick"
>
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
@@ -10,6 +15,7 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import MentionBox from '../mentions/MentionBox.vue';
@@ -22,7 +28,16 @@ export default {
},
},
computed: {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
items() {
return [
...this.standardAttributeVariables,
...this.customAttributeVariables,
];
},
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
@@ -34,6 +49,20 @@ export default {
description: variable.label,
}));
},
customAttributeVariables() {
return this.customAttributes.map(attribute => {
const attributePrefix =
attribute.attribute_model === 'conversation_attribute'
? 'conversation'
: 'contact';
return {
label: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
key: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
description: attribute.attribute_description,
};
});
},
},
methods: {
handleVariableClick(item = {}) {

View File

@@ -1,20 +1,40 @@
<template>
<div v-if="items.length" ref="mentionsListContainer" class="mention--box">
<div
ref="mentionsListContainer"
class="bg-white dark:bg-slate-800 rounded-md overflow-auto absolute w-full z-20 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-slate-100 dark:border-slate-700 mention--box"
>
<ul class="vertical dropdown menu">
<woot-dropdown-item
v-for="(item, index) in items"
:id="`mention-item-${index}`"
:key="item.key"
class="!mb-0"
@mouseover="onHover(index)"
>
<woot-button
class="canned-item__button"
:variant="index === selectedIndex ? '' : 'clear'"
:class="{ active: index === selectedIndex }"
<button
class="flex group flex-col gap-0.5 overflow-hidden cursor-pointer items-start py-2.5 px-2.5 justify-center w-full h-full text-left hover:bg-woot-50 dark:hover:bg-woot-800 border-b border-solid border-slate-100 dark:border-slate-700"
:class="{
' bg-woot-25 dark:bg-woot-800': index === selectedIndex,
}"
@click="onListItemSelection(index)"
>
<strong>{{ item.label }}</strong> - {{ item.description }}
</woot-button>
<p
class="text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 font-medium mb-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap min-w-0 max-w-full"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 mb-0 text-xs overflow-hidden text-ellipsis whitespace-nowrap min-w-0 max-w-full"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</button>
</woot-dropdown-item>
</ul>
</div>
@@ -29,6 +49,10 @@ export default {
type: Array,
default: () => {},
},
type: {
type: String,
default: 'canned',
},
},
data() {
return {
@@ -73,28 +97,17 @@ export default {
onSelect() {
this.$emit('mention-select', this.items[this.selectedIndex]);
},
variableKey(item = {}) {
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
},
},
};
</script>
<style scoped lang="scss">
.mention--box {
@apply bg-white dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border-t border-solid border-slate-75 dark:border-slate-800;
.dropdown-menu__item:last-child {
@apply pb-1;
}
.active {
@apply text-white dark:text-white;
&:hover {
@apply bg-woot-700 dark:bg-woot-700;
}
}
.button {
@apply transition-none h-8 leading-[1.4];
.dropdown-menu__item:last-child > button {
@apply border-0;
}
}

View File

@@ -10,6 +10,7 @@ export class DataManager {
async initDb() {
if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
@@ -19,6 +20,13 @@ export class DataManager {
},
});
// Store the database name in LocalStorage
const dbNames = JSON.parse(localStorage.getItem('cw-idb-names') || '[]');
if (!dbNames.includes(dbName)) {
dbNames.push(dbName);
localStorage.setItem('cw-idb-names', JSON.stringify(dbNames));
}
return this.db;
}

View File

@@ -41,6 +41,9 @@
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",

View File

@@ -92,7 +92,7 @@
},
"NETWORK": {
"NOTIFICATION": {
"TEXT": "Disconnected from Chatwoot"
"OFFLINE": "Offline"
},
"BUTTON": {
"REFRESH": "Refresh"

View File

@@ -3,6 +3,7 @@
"HEADER": {
"FILTER": "Filter by",
"SORT": "Sort by",
"LOCALE": "Locale",
"SETTINGS_BUTTON": "Settings",
"NEW_BUTTON": "New Article",
"DROPDOWN_OPTIONS": {
@@ -15,6 +16,12 @@
"MINE": "My Articles",
"DRAFT": "Draft Articles",
"ARCHIVED": "Archived Articles"
},
"LOCALE_SELECT": {
"TITLE": "Select locale",
"PLACEHOLDER": "Select locale",
"NO_RESULT": "No locale found",
"SEARCH_PLACEHOLDER": "Search locale"
}
},
"EDIT_HEADER": {

View File

@@ -10,6 +10,17 @@ describe('#messageStamp', () => {
});
describe('#messageTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers('modern');
const mockDate = new Date(2023, 4, 5);
jest.setSystemTime(mockDate);
});
afterEach(() => {
jest.useRealTimers();
});
it('should return the message date in the specified format if the message was sent in the current year', () => {
expect(TimeMixin.methods.messageTimestamp(1680777464)).toEqual(
'Apr 6, 2023'

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 bg-white dark:bg-slate-900"
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-10 bg-white dark:bg-slate-900"
>
<div class="flex items-center">
<woot-sidemenu-icon />
@@ -86,6 +86,45 @@
size="small"
color-scheme="secondary"
/>
<div class="relative">
<woot-button
v-if="shouldShowLocaleDropdown"
icon="globe"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openLocaleDropdown"
>
<div class="flex justify-between w-full min-w-0 items-center">
<span
class="inline-flex ml-1 rtl:ml-0 rtl:mr-1 items-center text-slate-800 dark:text-slate-100"
>
{{ selectedLocale }}
<Fluent-icon
class="dropdown-arrow"
icon="chevron-down"
size="14"
/>
</span>
</div>
</woot-button>
<div
v-if="showLocaleDropdown"
v-on-clickaway="closeLocaleDropdown"
class="dropdown-pane dropdown-pane--open"
>
<multiselect-dropdown-items
:options="switchableLocales"
:has-thumbnail="false"
:selected-items="[selectedLocale]"
:input-placeholder="
$t('HELP_CENTER.HEADER.LOCALE_SELECT.SEARCH_PLACEHOLDER')
"
:no-search-result="$t('HELP_CENTER.HEADER.LOCALE_SELECT.NO_RESULT')"
@click="onClickSelectItem"
/>
</div>
</div>
<woot-button
size="small"
icon="add"
@@ -103,12 +142,15 @@ import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
export default {
components: {
FluentIcon,
WootDropdownItem,
WootDropdownMenu,
MultiselectDropdownItems,
},
mixins: [clickaway],
props: {
@@ -124,16 +166,35 @@ export default {
type: String,
default: '',
},
selectedLocale: {
type: String,
default: '',
},
shouldShowSettings: {
type: Boolean,
default: false,
},
allLocales: {
type: Array,
default: () => [],
},
},
data() {
return {
showSortByDropdown: false,
showLocaleDropdown: false,
};
},
computed: {
shouldShowLocaleDropdown() {
return this.allLocales.length > 1;
},
switchableLocales() {
return this.allLocales.filter(
locale => locale.name !== this.selectedLocale
);
},
},
methods: {
openFilterModal() {
this.$emit('openModal');
@@ -146,8 +207,22 @@ export default {
this.$emit('close');
this.showSortByDropdown = false;
},
openLocaleDropdown() {
this.showLocaleDropdown = true;
},
closeLocaleDropdown() {
this.showLocaleDropdown = false;
},
onClickNewArticlePage() {
this.$emit('newArticlePage');
this.$emit('new-article-page');
},
onClickSelectItem(value) {
const { name, code } = value;
this.closeLocaleDropdown();
if (!name || name === this.selectedLocale) {
return;
}
this.$emit('change-locale', code);
},
},
};
@@ -155,7 +230,7 @@ export default {
<style scoped lang="scss">
.dropdown-pane--open {
@apply top-12 right-[9.25rem];
@apply absolute top-10 right-0 z-50 min-w-[8rem];
}
.dropdown-arrow {
@apply ml-1 rtl:ml-0 rtl:mr-1;

View File

@@ -22,7 +22,7 @@
v-if="isHelpCenterEnabled"
class="flex h-full min-h-0 overflow-hidden flex-1 px-0 bg-white dark:bg-slate-900"
>
<router-view />
<router-view @reload-locale="fetchPortalAndItsCategories" />
<command-bar />
<account-selector
:show-account-modal="showAccountModal"

View File

@@ -5,8 +5,11 @@
<article-header
:header-title="headerTitle"
:count="meta.count"
:selected-locale="activeLocaleName"
:all-locales="allowedLocales"
selected-value="Published"
@newArticlePage="newArticlePage"
@new-article-page="newArticlePage"
@change-locale="onChangeLocale"
/>
<article-table
:articles="articles"
@@ -32,6 +35,7 @@
</template>
<script>
import { mapGetters } from 'vuex';
import allLocales from 'shared/constants/locales.js';
import Spinner from 'shared/components/Spinner.vue';
import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader.vue';
@@ -58,6 +62,7 @@ export default {
meta: 'articles/getMeta',
isFetching: 'articles/isFetching',
currentUserId: 'getCurrentUserID',
getPortalBySlug: 'portals/portalBySlug',
}),
selectedCategory() {
return this.categories.find(
@@ -118,6 +123,28 @@ export default {
? this.selectedCategory.name
: '';
},
activeLocale() {
return this.$route.params.locale;
},
activeLocaleName() {
return allLocales[this.activeLocale];
},
portal() {
return this.getPortalBySlug(this.selectedPortalSlug);
},
allowedLocales() {
if (!this.portal) {
return [];
}
const { allowed_locales: allowedLocales } = this.portal.config;
return allowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
},
},
watch: {
$route() {
@@ -137,7 +164,7 @@ export default {
this.$store.dispatch('articles/index', {
pageNumber: pageNumber || this.pageNumber,
portalSlug: this.$route.params.portalSlug,
locale: this.$route.params.locale,
locale: this.activeLocale,
status: this.status,
authorId: this.author,
categorySlug: this.selectedCategorySlug,
@@ -152,6 +179,16 @@ export default {
portalSlug: this.$route.params.portalSlug,
});
},
onChangeLocale(locale) {
this.$router.push({
name: 'list_all_locale_articles',
params: {
portalSlug: this.$route.params.portalSlug,
locale,
},
});
this.$emit('reload-locale');
},
},
};
</script>

View File

@@ -50,6 +50,7 @@
:is-loading="uiFlags.isFetching"
:on-click-notification="openConversation"
:in-last-page="inLastPage"
@close="closeNotificationPanel"
/>
<div
v-if="records.length !== 0"

View File

@@ -0,0 +1,103 @@
<template>
<div>
<woot-button
size="expanded"
color-scheme="secondary"
variant="link"
@click="onClickOpenNotification()"
>
<div
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
>
<div
v-if="!notificationItem.read_at"
class="w-2 h-2 rounded-full bg-woot-500"
/>
<div v-else class="w-2 flex" />
<div
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
>
<div class="flex justify-between">
<div class="items-center flex">
<span class="font-bold text-slate-800 dark:text-slate-100">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
>
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div v-if="hasNotificationAssignee">
<thumbnail
:src="notificationAssigneeThumbnail"
size="16px"
:username="notificationAssigneeName"
/>
</div>
</div>
<div class="w-full flex">
<span
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ notificationItem.push_message_title }}
</span>
</div>
<span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
>
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
computed: {
notificationAssignee() {
const { primary_actor: primaryActor } = this.notificationItem;
return primaryActor?.meta?.assignee;
},
hasNotificationAssignee() {
return !!this.notificationAssignee;
},
notificationAssigneeName() {
return this.notificationAssignee?.name || '';
},
notificationAssigneeThumbnail() {
return this.notificationAssignee?.thumbnail || '';
},
},
methods: {
onClickOpenNotification() {
this.$emit('open-notification', this.notificationItem);
},
},
};
</script>

View File

@@ -1,70 +1,12 @@
<template>
<div class="flex-col py-2 px-2.5 overflow-auto h-full flex">
<woot-button
<notification-panel-item
v-for="notificationItem in notifications"
v-show="!isLoading"
:key="notificationItem.id"
size="expanded"
color-scheme="secondary"
variant="link"
@click="() => onClickNotification(notificationItem)"
>
<div
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
>
<div
v-if="!notificationItem.read_at"
class="w-2 h-2 rounded-full bg-woot-500"
/>
<div v-else class="w-2 flex" />
<div
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
>
<div class="flex justify-between">
<div class="items-center flex">
<span class="font-bold text-slate-800 dark:text-slate-100">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
>
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div>
<thumbnail
v-if="hasAssignee(notificationItem)"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px"
:username="notificationItem.primary_actor.meta.assignee.name"
/>
</div>
</div>
<div class="w-full flex">
<span
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ notificationItem.push_message_title }}
</span>
</div>
<span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
>
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
:notification-item="notificationItem"
@open-notification="onClickNotification"
/>
<empty-state
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
@@ -94,18 +36,16 @@
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import timeMixin from '../../../../mixins/time';
import NotificationPanelItem from './NotificationPanelItem.vue';
export default {
components: {
Thumbnail,
NotificationPanelItem,
Spinner,
EmptyState,
},
mixins: [timeMixin],
props: {
notifications: {
type: Array,
@@ -139,9 +79,7 @@ export default {
name: 'notifications_index',
});
}
},
hasAssignee(notification) {
return notification.primary_actor.meta?.assignee;
this.$emit('close');
},
},
};

View File

@@ -108,6 +108,7 @@ const sortConfig = {
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] = SORT_OPTIONS[sortKey] || [];
const [sortMethod, sortDirection] =
SORT_OPTIONS[sortKey] || SORT_OPTIONS.last_activity_at_desc;
return sortConfig[sortMethod](a, b, sortDirection);
};

View File

@@ -0,0 +1,34 @@
export default [
{
created_at: 1702411932, // Dec 12, 2023 12:12:12
id: 1,
last_activity_at: 1704408443, // Jan 04, 2024 14:47:23
messages: [{ content: 'test1' }],
priority: 'medium',
waiting_since: 0, // not waiting
},
{
created_at: 1699819932, // Nov 12, 2023 12:12:12
id: 2,
last_activity_at: 1704485532, // Jan 05, 2024 12:12:12
messages: [{ content: 'test2' }],
priority: 'low',
waiting_since: 1683645800, // May 09 2023 15:23:20
},
{
created_at: 1641413532, // Jan 05, 2022 12:12:12
id: 3,
last_activity_at: 1704408567, // Jan 04, 2024 14:49:27
messages: [{ content: 'test3' }],
priority: 'low',
waiting_since: 0, // not waiting
},
{
created_at: 1641413531, // Jan 05, 2022 12:12:11
id: 4,
last_activity_at: 1704408566, // Jan 04, 2024 14:49:26
messages: [{ content: 'test4' }],
priority: 'high',
waiting_since: 1683645801, // May 09 2023 15:23:21
},
];

View File

@@ -1,392 +1,130 @@
import commonHelpers from '../../../../helper/commons';
import getters from '../../conversations/getters';
/*
Order of conversations in the fixture is as follows:
- lastActivity: c0 < c3 < c2 < c1
- createdAt: c3 < c2 < c1 < c0
- priority: c1 < c2 < c0 < c3
- waitingSince: c1 > c3 > c0 < c2
*/
import conversations from './conversations.fixtures';
// loads .last() helper
commonHelpers();
describe('#getters', () => {
describe('#getAllConversations', () => {
it('order conversations based on last activity', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
};
it('returns conversations ordered by lastActivityAt in descending order if no sort order is available', () => {
const state = { allConversations: [...conversations] };
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on last activity with ascending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
chatSortFilter: 'latest_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on created at', () => {
it('returns conversations ordered by lastActivityAt in descending order if invalid sort order is available', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_last',
allConversations: [...conversations],
chatSortFilter: 'latest',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on created at with descending order', () => {
it('returns conversations ordered by lastActivityAt in descending order if chatStatusFilter = last_activity_at_desc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_first',
allConversations: [...conversations],
chatSortFilter: 'last_activity_at_desc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on default order', () => {
it('returns conversations ordered by lastActivityAt in ascending order if chatStatusFilter = last_activity_at_asc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
allConversations: [...conversations],
chatSortFilter: 'last_activity_at_asc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on priority', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_first',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
conversations[0],
conversations[3],
conversations[2],
conversations[1],
]);
});
it('order conversations based on with descending order', () => {
it('returns conversations ordered by createdAt in descending order if chatStatusFilter = created_at_desc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_last',
allConversations: [...conversations],
chatSortFilter: 'created_at_desc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
conversations[0],
conversations[1],
conversations[2],
conversations[3],
]);
});
it('order conversations based on waiting_since', () => {
it('returns conversations ordered by createdAt in ascending order if chatStatusFilter = created_at_asc', () => {
const state = {
allConversations: [
{
id: 3,
created_at: 1683645800,
waiting_since: 0,
},
{
id: 4,
created_at: 1683645799,
waiting_since: 0,
},
{
id: 1,
created_at: 1683645801,
waiting_since: 1683645802,
},
{
id: 2,
created_at: 1683645803,
waiting_since: 1683645800,
},
],
chatSortFilter: 'waiting_since_last',
allConversations: [...conversations],
chatSortFilter: 'created_at_asc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
created_at: 1683645803,
waiting_since: 1683645800,
},
{
id: 1,
created_at: 1683645801,
waiting_since: 1683645802,
},
{
id: 4,
created_at: 1683645799,
waiting_since: 0,
},
{
id: 3,
created_at: 1683645800,
waiting_since: 0,
},
conversations[3],
conversations[2],
conversations[1],
conversations[0],
]);
});
it('returns conversations ordered by priority in descending order if chatStatusFilter = priority_desc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'priority_desc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[3],
conversations[0],
conversations[1],
conversations[2],
]);
});
it('returns conversations ordered by priority in ascending order if chatStatusFilter = priority_asc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'priority_asc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[1],
conversations[2],
conversations[0],
conversations[3],
]);
});
it('returns conversations ordered by longest waiting if chatStatusFilter = waiting_since_asc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'waiting_since_asc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[1],
conversations[3],
conversations[2],
conversations[0],
]);
});
});

View File

@@ -44,10 +44,29 @@ export const clearLocalStorageOnLogout = () => {
};
export const deleteIndexedDBOnLogout = async () => {
const dbs = await window.indexedDB.databases();
dbs.forEach(db => {
window.indexedDB.deleteDatabase(db.name);
let dbs = [];
try {
dbs = await window.indexedDB.databases();
dbs = dbs.map(db => db.name);
} catch (e) {
dbs = JSON.parse(localStorage.getItem('cw-idb-names') || '[]');
}
dbs.forEach(dbName => {
const deleteRequest = window.indexedDB.deleteDatabase(dbName);
deleteRequest.onerror = event => {
// eslint-disable-next-line no-console
console.error(`Error deleting database ${dbName}.`, event);
};
deleteRequest.onsuccess = () => {
// eslint-disable-next-line no-console
console.log(`Database ${dbName} deleted successfully.`);
};
});
localStorage.removeItem('cw-idb-names');
};
export const clearCookiesOnLogout = () => {

View File

@@ -51,6 +51,9 @@ if (window.errorLoggingConfig) {
/safari-extension:/i,
],
integrations: [new Integrations.BrowserTracing()],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}

View File

@@ -35,6 +35,9 @@ if (window.errorLoggingConfig) {
/safari-extension:/i,
],
integrations: [new Integrations.BrowserTracing()],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}

View File

@@ -17,6 +17,7 @@ import {
removeSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { createTypingIndicator } from '@chatwoot/utils';
const TYPING_INDICATOR_IDLE_TIME = 4000;
export default {
@@ -54,7 +55,15 @@ export default {
},
data() {
return {
idleTimer: null,
typingIndicator: createTypingIndicator(
() => {
this.$emit('typing-on');
},
() => {
this.$emit('typing-off');
},
TYPING_INDICATOR_IDLE_TIME
),
};
},
computed: {
@@ -137,28 +146,11 @@ export default {
this.$emit('input', event.target.value);
this.resizeTextarea();
},
resetTyping() {
this.$emit('typing-off');
this.idleTimer = null;
},
turnOffIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
},
onKeyup() {
if (!this.idleTimer) {
this.$emit('typing-on');
}
this.turnOffIdleTimer();
this.idleTimer = setTimeout(
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
this.typingIndicator.start();
},
onBlur() {
this.turnOffIdleTimer();
this.resetTyping();
this.typingIndicator.stop();
this.$emit('blur');
},
onFocus() {

View File

@@ -19,11 +19,7 @@
<reply-to-chip :reply-to="replyTo" />
</div>
<div class="flex gap-1">
<drag-wrapper
class="space-y-2"
direction="right"
@dragged="toggleReply"
>
<div class="space-y-2">
<AgentMessageBubble
v-if="shouldDisplayAgentMessage"
:content-type="contentType"
@@ -54,7 +50,7 @@
<file-bubble v-else :url="attachment.data_url" />
</div>
</div>
</drag-wrapper>
</div>
<div class="flex flex-col justify-end">
<message-reply-button
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@@ -96,7 +92,6 @@ import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
import DragWrapper from 'widget/components/DragWrapper.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
@@ -109,7 +104,6 @@ export default {
FileBubble,
MessageReplyButton,
ReplyToChip,
DragWrapper,
},
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: {

View File

@@ -0,0 +1,10 @@
class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
def perform
# rubocop:disable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
end

View File

@@ -1,6 +1,6 @@
class SendOnSlackJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 6
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do

View File

@@ -11,6 +11,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to reopen snoozed conversations
Conversations::ReopenSnoozedConversationsJob.perform_later
# Job to reopen snoozed notifications
Notification::ReopenSnoozedNotificationsJob.perform_later
# Job to auto-resolve conversations
Account::ConversationsResolutionSchedulerJob.perform_later

View File

@@ -8,6 +8,8 @@ class ActionCableListener < BaseListener
end
def notification_deleted(event)
return if event.data[:notification].user.blank?
notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_DELETED, { notification: notification.push_event_data, unread_count: unread_count, count: count })

View File

@@ -83,7 +83,7 @@ class ReplyMailbox < ApplicationMailbox
end
def validate_resource(resource)
raise "#{resource.class.name} not found" if resource.nil?
raise "Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
resource
end

View File

@@ -7,11 +7,19 @@ class SupportMailbox < ApplicationMailbox
:decorate_mail
def process
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
# to turn off spam conversation creation
return unless @account.active?
# prevent loop from chatwoot notification emails
return if notification_email_from_chatwoot?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce seperately and mark the contact as invalid
# we are checking for @ since the returned value could be "\"\"" for some email clients
return unless original_sender_email.include?('@')
ActiveRecord::Base.transaction do
find_or_create_contact
find_or_create_conversation
@@ -56,6 +64,10 @@ class SupportMailbox < ApplicationMailbox
mail['In-Reply-To'].try(:value)
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
account_id: @account.id,
@@ -74,7 +86,7 @@ class SupportMailbox < ApplicationMailbox
end
def find_or_create_contact
@contact = @inbox.contacts.find_by(email: @processed_mail.original_sender&.downcase)
@contact = @inbox.contacts.find_by(email: original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else

View File

@@ -101,7 +101,7 @@ class Message < ApplicationRecord
# [:external_error : Can specify if the message creation failed due to an error at external API
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id], coder: JSON
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@@ -299,6 +299,10 @@ class Message < ApplicationRecord
end
def dispatch_update_event
# ref: https://github.com/rails/rails/issues/44500
# we want to skip the update event if the message is not updated
return if previous_changes.blank?
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end

View File

@@ -3,6 +3,7 @@
# Table name: notifications
#
# id :bigint not null, primary key
# last_activity_at :datetime
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
@@ -18,6 +19,7 @@
# Indexes
#
# index_notifications_on_account_id (account_id)
# index_notifications_on_last_activity_at (last_activity_at)
# index_notifications_on_user_id (user_id)
# uniq_primary_actor_per_account_notifications (primary_actor_type,primary_actor_id)
# uniq_secondary_actor_per_account_notifications (secondary_actor_type,secondary_actor_id)
@@ -41,6 +43,7 @@ class Notification < ApplicationRecord
enum notification_type: NOTIFICATION_TYPES
before_create :set_last_activity_at
after_create_commit :process_notification_delivery, :dispatch_create_event
after_destroy_commit :dispatch_destroy_event
@@ -137,4 +140,8 @@ class Notification < ApplicationRecord
def dispatch_destroy_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self)
end
def set_last_activity_at
self.last_activity_at = created_at
end
end

View File

@@ -21,7 +21,7 @@ class InboxPolicy < ApplicationPolicy
def show?
# FIXME: for agent bots, lets bring this validation to policies as well in future
return true if @user.blank?
return true if @user.is_a?(AgentBot)
Current.user.assigned_inboxes.include? record
end

View File

@@ -49,8 +49,10 @@ class ActionService
end
def assign_team(team_ids = [])
return unassign_team if team_ids[0].zero?
return unless team_belongs_to_account?(team_ids)
return unassign_team if team_ids[0]&.zero?
# 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)
@conversation.update!(team_id: team_ids[0])
end

View File

@@ -100,6 +100,12 @@ class Telegram::IncomingMessageService
def attach_files
return unless file
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
if file_download_path.blank?
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
return
end
attachment_file = Down.download(
inbox.channel.get_telegram_file_path(file[:file_id])
)

View File

@@ -107,11 +107,9 @@ class Twilio::IncomingMessageService
def attach_files
return if params[:MediaUrl0].blank?
attachment_file = Down.download(
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
attachment_file = download_attachment_file
return if attachment_file.blank?
attachment = @message.attachments.new(
account_id: @message.account_id,
@@ -126,4 +124,29 @@ class Twilio::IncomingMessageService
@message.save!
end
def download_attachment_file
download_with_auth
rescue Down::Error => e
handle_download_attachment_error(e)
end
def download_with_auth
Down.download(
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
end
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
def handle_download_attachment_error(error)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}"
if error.message.include?('401 Unauthorized')
Down.download(params[:MediaUrl0])
else
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
nil
end
end
end

View File

@@ -8,13 +8,26 @@
</header>
<section class="main-content__body">
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |c| %>
<div class="field-unit">
<% @allowed_configs.each do |key| %>
<div class="flex mb-8">
<div class="field-unit__label">
<%= form.label "app_config[#{c}]", c %>
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
</div>
<div class="field-unit__field">
<%= form.text_field "app_config[#{c}]", value: @app_config[c] %>
<div class="field-unit__field -mt-2 ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
<%if @installation_configs[key]&.dig('description').present? %>
<p class="text-slate-400 text-xs italic pt-2">
<%= @installation_configs[key]&.dig('description') %>
</p>
<% end %>
</div>
</div>
<% end %>

View File

@@ -1,5 +1,5 @@
shared: &shared
version: '3.4.0'
version: '3.5.0'
development:
<<: *shared

View File

@@ -1,23 +1,46 @@
# if you dont specify locked attribute, the default value will be true
# if you don't specify locked attribute, the default value will be true
# which means the particular config will be locked
- name: INSTALLATION_NAME
value: 'Chatwoot'
display_title: 'Installation Name'
description: 'The installation wide name that would be used in the dashboard, title etc.'
- name: LOGO_THUMBNAIL
value: '/brand-assets/logo_thumbnail.svg'
display_title: 'Logo Thumbnail'
description: 'The thumbnail that would be used for favicon (512px X 512px)'
- name: LOGO
value: '/brand-assets/logo.svg'
display_title: 'Logo'
description: 'The logo that would be used on the dashboard, login page etc.'
- name: LOGO_DARK
value: '/brand-assets/logo_dark.svg'
display_title: 'Logo Dark Mode'
description: 'The logo that would be used on the dashboard, login page etc. for dark mode'
- name: BRAND_URL
value: 'https://www.chatwoot.com'
display_title: 'Brand URL'
description: 'The URL that would be used in emails under the section “Powered By”'
- name: WIDGET_BRAND_URL
value: 'https://www.chatwoot.com'
display_title: 'Widget Brand URL'
description: 'The URL that would be used in the widget under the section “Powered By”'
- name: BRAND_NAME
value: 'Chatwoot'
display_title: 'Brand Name'
description: 'The name that would be used in emails and the widget'
- name: TERMS_URL
value: 'https://www.chatwoot.com/terms-of-service'
display_title: 'Terms URL'
description: 'The terms of service URL displayed in Signup Page'
- name: PRIVACY_URL
value: 'https://www.chatwoot.com/privacy-policy'
display_title: 'Privacy URL'
description: 'The privacy policy URL displayed in the app'
- name: DISPLAY_MANIFEST
value: true
display_title: 'Chatwoot Metadata'
description: 'Display default Chatwoot metadata like favicons and upgrade warnings'
type: boolean
- name: MAILER_INBOUND_EMAIL_DOMAIN
value:
locked: false
@@ -72,8 +95,6 @@
value: self-hosted
- name: CSML_EDITOR_HOST
value:
- name: LOGO_DARK
value: '/brand-assets/logo_dark.svg'
- name: INSTALLATION_PRICING_PLAN
value: 'community'
- name: INSTALLATION_PRICING_PLAN_QUANTITY

View File

@@ -29,8 +29,8 @@ common: &default_settings
# independently. If `false`, all logging-related features are disabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_ENABLED', false) %>
forwarding:
# If `true`, the agent captures log records emitted by this application.
enabled: true
# If `true`, the agent captures log records emitted by this application
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) == "false" ? false : true %>
# Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 30000
metrics:
@@ -40,7 +40,7 @@ common: &default_settings
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
# This requires a log forwarder to send your log files to New Relic.
# This should not be used when forwarding is enabled.
enabled: false
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_DECORATING_ENABLED', false) %>
# Environment-specific settings are in this section.

View File

@@ -0,0 +1,6 @@
class AddLastActivityAtToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :last_activity_at, :datetime, default: -> { 'CURRENT_TIMESTAMP' }
add_index :notifications, :last_activity_at
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_12_19_000743) do
ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -731,7 +731,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_000743) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "snoozed_until"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
t.index ["user_id"], name: "index_notifications_on_user_id"

View File

@@ -9,12 +9,14 @@ module Enterprise::SuperAdmin::AppConfigsController
@allowed_configs = %w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
else
super

View File

@@ -21,6 +21,9 @@ module Enterprise::Channelable
return if audited_changes.blank?
# skip audit log creation if the only change is whatsapp channel template update
return if messaging_template_updates?(audited_changes)
Enterprise::AuditLog.create(
auditable_id: auditable_id,
auditable_type: auditable_type,
@@ -30,5 +33,13 @@ module Enterprise::Channelable
audited_changes: audited_changes
)
end
def messaging_template_updates?(changes)
# if there is more than one key, return false
return false unless changes.keys.length == 1
# if the only key is message_templates_last_updated, return true
changes.key?('message_templates_last_updated')
end
end
end

View File

@@ -14,7 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
private
def process_subscription_updated
plan = find_plan(subscription['plan']['product'])
plan = find_plan(subscription['plan']['product']) if subscription['plan'].present?
# skipping self hosted plan events
return if plan.blank? || account.blank?

View File

@@ -4,7 +4,7 @@ class ChatGpt
end
def initialize(context_sections = '')
@model = 'gpt-4'
@model = 'gpt-4-1106-preview'
@messages = [system_message(context_sections)]
end
@@ -53,7 +53,7 @@ class ChatGpt
def request_gpt
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" }
body = { model: @model, messages: @messages }.to_json
body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"

View File

@@ -26,12 +26,13 @@ class ConfigLoader
reconcile_feature_config
end
private
def general_configs
@config_path ||= Rails.root.join('config')
@general_configs ||= YAML.safe_load(File.read("#{@config_path}/installation_config.yml")).freeze
end
private
def account_features
@account_features ||= YAML.safe_load(File.read("#{@config_path}/features.yml")).freeze
end

View File

@@ -12,6 +12,7 @@ class GlobalConfig
config[config_key] = load_from_cache(config_key)
end
typecast_config(config)
config.with_indifferent_access
end
@@ -28,6 +29,14 @@ class GlobalConfig
private
def typecast_config(config)
general_configs = ConfigLoader.new.general_configs
config.each do |config_key, config_value|
config_type = general_configs.find { |c| c['name'] == config_key }&.dig('type')
config[config_key] = ActiveRecord::Type::Boolean.new.cast(config_value) if config_type == 'boolean'
end
end
def load_from_cache(config_key)
cache_key = "#{VERSION}:#{KEY_PREFIX}:#{config_key}"
cached_value = $alfred.with { |conn| conn.get(cache_key) }

View File

@@ -97,7 +97,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
post_message if message_content.present?
upload_file if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound => e
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e
hook.prompt_reauthorization!
hook.disable

View File

@@ -12,10 +12,10 @@
# end
#
class Redis::LockManager
# Default lock timeout set to 2 seconds. This means that if the lock isn't released
# within 2 seconds, it will automatically expire.
# Default lock timeout set to 1 second. This means that if the lock isn't released
# within 1 second, it will automatically expire.
# This helps to avoid deadlocks in case the process holding the lock crashes or fails to release it.
LOCK_TIMEOUT = 2.seconds
LOCK_TIMEOUT = 1.second
# Attempts to acquire a lock for the given key.
#

View File

@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.4.0",
"version": "3.5.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -31,8 +31,8 @@
],
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "1.0.3",
"@chatwoot/utils": "^0.0.16",
"@chatwoot/prosemirror-schema": "1.0.5",
"@chatwoot/utils": "^0.0.21",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
"@radix-ui/colors": "^1.0.1",
@@ -169,4 +169,4 @@
"scss-lint"
]
}
}
}

View File

@@ -323,6 +323,9 @@ RSpec.describe 'Conversations API', type: :request do
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_status' do
let(:conversation) { create(:conversation, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:pending_conversation) { create(:conversation, inbox: inbox, account: account, status: 'pending') }
let(:agent_bot) { create(:agent_bot, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -424,6 +427,42 @@ RSpec.describe 'Conversations API', type: :request do
# expect(conversation.reload.status).to eq('pending')
# end
end
context 'when it is an authenticated bot' do
# this test will basically ensure that the status actually changes
# regardless of the value to be done
it 'returns authorized for arbritrary status' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update!(status: 'open')
expect(conversation.reload.status).to eq('open')
snoozed_until = (DateTime.now.utc + 2.days).to_i
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'snoozed', snoozed_until: snoozed_until },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('snoozed')
end
it 'triggers handoff event when moving from pending to open' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{pending_conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(pending_conversation.reload.status).to eq('open')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Events::Types::CONVERSATION_BOT_HANDOFF, kind_of(Time), conversation: pending_conversation, notifiable_assignee_change: false,
changed_attributes: anything, performed_by: anything)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_priority' do

View File

@@ -97,4 +97,56 @@ RSpec.describe Inbox do
end
end
end
describe 'audit log with whatsapp channel' do
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:inbox) { channel.inbox }
before do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
.with(
headers: {
'Accept' => '*/*',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'User-Agent' => 'Ruby'
}
)
.to_return(status: 200, body: '', headers: {})
end
context 'when inbox is created' do
it 'has associated audit log created' do
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1)
end
end
context 'when inbox is updated' do
it 'has associated audit log created' do
inbox.update(name: 'Updated Inbox')
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
end
end
context 'when channel is updated' do
it 'has associated audit log created' do
previous_phone_number = inbox.channel.phone_number
new_phone_number = '1234567890'
inbox.channel.update(phone_number: new_phone_number)
# check if channel update creates an audit log against inbox
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
# Check for the specific phone_number update in the audit log
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update',
audited_changes: { 'phone_number' => [previous_phone_number, new_phone_number] }).count).to eq(1)
end
end
context 'when template sync runs' do
it 'has no associated audit log created' do
channel.sync_templates
# check if template sync does not create an audit log
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(0)
end
end
end
end

View File

@@ -273,6 +273,33 @@ FactoryBot.define do
initialize_with { attributes }
end
factory :instagram_message_unsupported_event, class: Hash do
entry do
[
{
'id': 'instagram-message-unsupported-id-123',
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
'id': 'Sender-id-1'
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
},
'timestamp': '2021-09-08T06:34:04+0000',
'message': {
'mid': 'unsupported-message-id-1',
'is_unsupported': true
}
}
]
}
]
end
initialize_with { attributes }
end
factory :messaging_seen_event, class: Hash do
entry do
[

View File

@@ -0,0 +1,102 @@
X-Original-To: unique-id@reply.example.com
Received: from gate.forward.smtp.example.com (mxd [192.0.2.1]) by mx.example.net with ESMTP id JANE3UihQWCm3SPLwYMiwA for <unique-id@reply.example.com>; Mon, 01 Jan 2024 08:27:12.905 +0000 (UTC)
Return-Path: <>
X-Virus-Scanned: OK
Authentication-Results: smtp6.gate.example.com; iprev=pass policy.iprev="192.0.2.2"; spf=neutral smtp.mailfrom="" smtp.helo="backend.example.com"; dkim=none (message not signed) header.d=none
X-Suspicious-Flag: NO
X-Classification-ID: 9026a23e-a87f-11ee-b226-52540050e3e0-1-1
Received: from [192.0.2.2] ([192.0.2.2:52052] helo=backend.example.com)
by smtp6.gate.example.com (envelope-from <>)
(ecelerity 4.2.38.62370 r(:)) with ESMTPS (cipher=DHE-RSA-AES256-GCM-SHA384)
id 69/60-03303-06772956; Mon, 01 Jan 2024 03:27:12 -0500
Received: by backend.example.com (Postfix, from userid 5000)
id BE58147CF5; Mon, 1 Jan 2024 03:27:12 -0500 (EST)
X-Sieve: Pigeonhole Sieve 0.5.12 (f22f7ab3)
X-Sieve-Redirected-From: support@example.com
Delivered-To: support@example.com
Delivered-To: support@example.com
Received: from director.example.com ([192.0.2.3])
by backend.example.com with LMTP
id AB5kLWB3kmV0bgAAStNUoA
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
Received: from proxy.example.com ([192.0.2.3])
by director.example.com with LMTP
id 0BPqLGB3kmWXKQAAfY0hYg
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
Received: from smtp.example.com ([192.0.2.3])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
by proxy.example.com with LMTPS
id yGPPLGB3kmXQNwAAyH2SIw
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
X-Spam-Threshold: 95
X-Spam-Score: 0
X-Spam-Flag: NO
X-Virus-Scanned: OK
X-Orig-To: support@example.com
X-Originating-Ip: [192.0.2.4]
Received: from [192.0.2.4] ([192.0.2.4:47194] helo=smtp.example.com)
by smtp36.gate.example.com (envelope-from <>)
(ecelerity 4.2.38.62370 r(:)) with ESMTPS (cipher=DHE-RSA-AES256-GCM-SHA384)
id 57/13-02844-06772956; Mon, 01 Jan 2024 03:27:12 -0500
Received: by smtp5.relay.example.com (SMTP Server)
id 8D329A008A; Mon, 1 Jan 2024 03:27:12 -0500 (EST)
Date: Mon, 1 Jan 2024 03:27:12 -0500 (EST)
From: "" (Mail Delivery System)
Subject: Undelivered Mail Returned to Sender
To: support@example.com
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="AE732A0081.1704097632/smtp5.relay.example.com"
Message-Id: <20240101082712.8D329A008A@smtp5.relay.example.com>
This is a MIME-encapsulated message.
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Notification
Content-Type: text/plain; charset=us-ascii
This is the mail system at host smtp5.relay.example.com.
I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.
For further assistance, please send mail to postmaster.
If you do so, please include this problem report. You can
delete your own text from the attached returned message.
The mail system
<noreply@example-service.com>: host
mx.example-service.com.cust.a.hostedemail.com[198.51.100.4] said: 554 5.7.1
<noreply@example-service.com>: Recipient address rejected: user
noreply@example-service.com does not exist (in reply to RCPT TO command)
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Delivery report
Content-Type: message/delivery-status
Reporting-MTA: dns; smtp5.relay.example.com
X-SMTP-Server-Queue-ID: AE732A0081
X-SMTP-Server-Sender: rfc822; support@example.com
Arrival-Date: Mon, 1 Jan 2024 03:27:11 -0500 (EST)
Final-Recipient: rfc822; noreply@example-service.com
Original-Recipient: rfc822;noreply@example-service.com
Action: failed
Status: 5.7.1
Remote-MTA: dns; mx.example-service.com.cust.a.hostedemail.com
Diagnostic-Code: smtp; 554 5.7.1 <noreply@example-service.com>: Recipient
address rejected: user noreply@example-service.com does not exist
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Undelivered Message
Content-Type: message/rfc822
Return-Path: <support@example.com>
X-Milter-Dummy:
DKIM-Signature: v=1; a=rsa-sha256; c

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Notification::ReopenSnoozedNotificationsJob do
let!(:snoozed_till_5_minutes_ago) { create(:notification, snoozed_until: 5.minutes.ago) }
let!(:snoozed_till_tomorrow) { create(:notification, snoozed_until: 1.day.from_now) }
let!(:snoozed_indefinitely) { create(:notification) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'reopens snoozed notifications whose snooze until has passed' do
described_class.perform_now
expect(snoozed_till_5_minutes_ago.reload.snoozed_until).to be_nil
expect(snoozed_till_tomorrow.reload.snoozed_until.to_date).to eq 1.day.from_now.to_date
expect(snoozed_indefinitely.reload.snoozed_until).to be_nil
end
end
end

View File

@@ -26,6 +26,11 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
it 'triggers Notification::ReopenSnoozedNotificationsJob' do
expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Account::ConversationsResolutionSchedulerJob' do
expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once
described_class.perform_now

View File

@@ -28,6 +28,7 @@ describe Webhooks::InstagramEventsJob do
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access }
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access }
let(:fb_object) { double }
describe '#perform' do
@@ -45,6 +46,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'creates standby message in the instagram inbox' do
@@ -157,6 +159,22 @@ describe Webhooks::InstagramEventsJob do
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(unsupported_message_event[:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
end
end
end

View File

@@ -42,6 +42,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@@ -59,6 +64,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config and sentinel passwords will be empty' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@@ -77,6 +87,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config and redis password is replaced in sentinel config' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")

View File

@@ -16,6 +16,17 @@ RSpec.describe SupportMailbox do
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }

View File

@@ -39,5 +39,10 @@ describe ActionService do
action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil
end
it 'unassigns the team if team_id is nil' do
action_service.assign_team([nil])
expect(conversation.reload.team).to be_nil
end
end
end

View File

@@ -215,6 +215,26 @@ describe Telegram::IncomingMessageService do
end
end
context 'when the API call to get the download path returns an error' do
it 'does not process the attachment' do
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return(nil)
params = {
'update_id' => 2_342_342_343_242,
'message' => {
'document' => {
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
'file_name' => 'Screenshot 2021-09-27 at 2.01.14 PM.png',
'mime_type' => 'application/png',
'file_size' => 536_392
}
}.merge(message_params)
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.messages.first.attachments.count).to eq(0)
end
end
context 'when valid location message params' do
it 'creates appropriate conversations, message and contacts' do
params = {

View File

@@ -169,5 +169,65 @@ describe Twilio::IncomingMessageService do
expect(twilio_sms_channel.inbox.conversations.count).to eq(1)
end
end
context 'when a message with an attachment is received' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: {})
end
let(:params_with_attachment) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'creates a new message with media in existing conversation' do
described_class.new(params: params_with_attachment).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
context 'when there is an error downloading the attachment' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_raise(Down::Error.new('Download error'))
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: {})
end
let(:params_with_attachment_error) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'retries downloading the attachment without a token after an error' do
expect do
described_class.new(params: params_with_attachment_error).perform
end.not_to raise_error
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
end
end

View File

@@ -3156,10 +3156,10 @@
"@braid/vue-formulate-i18n" "^1.16.0"
is-plain-object "^3.0.1"
"@chatwoot/prosemirror-schema@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.3.tgz#8599e517d42cb31fabf977554ade666eb6316ef0"
integrity sha512-BIVxV7c8x0vbWtWxGPk/VnBrtC1CV0TzZd+GPZC49irVcQQ2vAwgOYaZ/1qcFe9M3jv0kWAWOPqQAfbB5RBB7g==
"@chatwoot/prosemirror-schema@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.5.tgz#d6053692beae59d466ac0b04128fa157f59eb176"
integrity sha512-dOzkZ2K53PPbE9AQB0RHlVs+GIEyHHdXeeW44dNSEuULwH99PmTpzA2r45QX3uaVa2j7Mip76AQbJZGKbM2fxg==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"
@@ -3177,10 +3177,10 @@
prosemirror-utils "^0.9.6"
prosemirror-view "^1.17.2"
"@chatwoot/utils@^0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.16.tgz#84a0ade7f0377a3c7a95b2e5fa3c3b3d7d045339"
integrity sha512-PcWEJ+LeJlDkjOOCHyi7wLaaBsPpedDh1jBblkqOYnt8PvKHSttq/Fusi6u6EcxPhIw1umgq5A/k8sWozmZ4iQ==
"@chatwoot/utils@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.21.tgz#f9116daac0514a8a8fa6ce594efff10062222be0"
integrity sha512-eUDJ1K5x1rFlBywRctU3hXXiJ1U0EZiklowNl/YJOh1/BWDns4It3DWrQmAcjvsNbEUNWMfY+ShJmjdeei71Cw==
dependencies:
date-fns "^2.29.1"