Merge branch 'release/3.5.0'

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

View File

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

View File

@ -113,13 +113,13 @@ GEM
kaminari (>= 1.0) kaminari (>= 1.0)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.0) administrate-field-active_storage (1.0.1)
administrate (>= 0.2.2) administrate (>= 0.2.2)
rails (>= 7.0) rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0) administrate-field-belongs_to_search (0.9.0)
administrate (>= 0.3, < 1.0) administrate (>= 0.3, < 1.0)
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.1) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
@ -230,9 +230,9 @@ GEM
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (6.4.2) factory_bot (6.4.5)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.2) factory_bot_rails (6.4.3)
factory_bot (~> 6.4) factory_bot (~> 6.4)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
@ -472,7 +472,7 @@ GEM
activerecord (>= 5.2) activerecord (>= 5.2)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.5) net-imap (0.4.9)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@ -487,15 +487,15 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.6.0) nio4r (2.7.0)
nokogiri (1.15.5) nokogiri (1.16.0)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.5-arm64-darwin) nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.5-x86_64-darwin) nokogiri (1.16.0-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.5-x86_64-linux) nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1) numo-narray (0.9.2.1)
oauth (1.1.0) oauth (1.1.0)
@ -511,7 +511,7 @@ GEM
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
version_gem (~> 1.1) version_gem (~> 1.1)
omniauth (2.1.1) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
@ -553,7 +553,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (5.0.1) public_suffix (5.0.1)
puma (6.3.1) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -568,8 +568,8 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (3.0.6) rack-protection (3.1.0)
rack rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
@ -841,8 +841,8 @@ DEPENDENCIES
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate (>= 0.19.0) administrate (>= 0.19.0)
administrate-field-active_storage (>= 1.0.0) administrate-field-active_storage (>= 1.0.1)
administrate-field-belongs_to_search administrate-field-belongs_to_search (>= 0.9.0)
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@ -870,7 +870,7 @@ DEPENDENCIES
elastic-apm elastic-apm
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.2) factory_bot_rails (>= 6.4.3)
faker faker
fcm fcm
flag_shih_tzu flag_shih_tzu
@ -905,7 +905,7 @@ DEPENDENCIES
neighbor neighbor
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm newrelic_rpm
omniauth omniauth (>= 2.1.2)
omniauth-google-oauth2 omniauth-google-oauth2
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0) 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> <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 @outgoing_echo ? recipient_id : sender_id
end end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id def sender_id
@messaging[:sender][:id] @messaging[:sender][:id]
end end
@ -118,7 +122,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def message_params def message_params
{ params = {
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: message_type, message_type: message_type,
@ -129,6 +133,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
in_reply_to_external_id: message_reply_attributes in_reply_to_external_id: message_reply_attributes
} }
} }
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end end
def already_sent_from_chatwoot? def already_sent_from_chatwoot?

View File

@ -60,13 +60,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_status 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 set_conversation_status
@status = @conversation.save! @status = @conversation.save!
else else
@status = @conversation.toggle_status @status = @conversation.toggle_status
end 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 end
def toggle_priority def toggle_priority

View File

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

View File

@ -9,6 +9,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
.map { |name, serialized_value| [name, serialized_value['value']] } .map { |name, serialized_value| [name, serialized_value['value']] }
.to_h .to_h
# rubocop:enable Style/HashTransformValues # 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 end
def create def create

View File

@ -76,7 +76,11 @@
&.left { &.left {
.bubble { .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 { &.is-image {
@apply rounded-lg; @apply rounded-lg;

View File

@ -1,24 +1,33 @@
<template> <template>
<transition name="network-notification-fade" tag="div"> <transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container"> <div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div class="ui-notification"> <div
<fluent-icon icon="wifi-off" /> class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
<p class="ui-notification-text"> >
{{ <fluent-icon
useInstallationName( icon="wifi-off"
$t('NETWORK.NOTIFICATION.TEXT'), class="text-yellow-700/50 dark:text-yellow-50"
globalConfig.installationName size="18"
) />
}} <span
</p> class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
<woot-button variant="clear" size="small" @click="refreshPage"> >
{{ $t('NETWORK.BUTTON.REFRESH') }} {{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
</woot-button> </span>
<woot-button <woot-button
variant="smooth" :title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small" size="small"
color-scheme="warning" 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" @click="closeNotification"
/> />
</div> </div>
@ -47,7 +56,12 @@ export default {
mounted() { mounted() {
window.addEventListener('offline', this.updateOnlineStatus); window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => { 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> </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 eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } 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 { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
@ -140,6 +143,15 @@ export default {
}, },
data() { data() {
return { return {
typingIndicator: createTypingIndicator(
() => {
this.$emit('typing-on');
},
() => {
this.$emit('typing-off');
},
TYPING_INDICATOR_IDLE_TIME
),
showUserMentions: false, showUserMentions: false,
showCannedMenu: false, showCannedMenu: false,
showVariables: false, showVariables: false,
@ -638,15 +650,6 @@ export default {
hideMentions() { hideMentions() {
this.showUserMentions = false; this.showUserMentions = false;
}, },
resetTyping() {
this.$emit('typing-off');
this.idleTimer = null;
},
turnOffIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
},
handleLineBreakWhenEnterToSendEnabled(event) { handleLineBreakWhenEnterToSendEnabled(event) {
if ( if (
hasPressedEnterAndNotCmdOrShift(event) && hasPressedEnterAndNotCmdOrShift(event) &&
@ -666,14 +669,7 @@ export default {
} }
}, },
onKeyup() { onKeyup() {
if (!this.idleTimer) { this.typingIndicator.start();
this.$emit('typing-on');
}
this.turnOffIdleTimer();
this.idleTimer = setTimeout(
() => this.resetTyping(),
TYPING_INDICATOR_IDLE_TIME
);
this.updateImgToolbarOnDelete(); this.updateImgToolbarOnDelete();
}, },
onKeydown(event) { onKeydown(event) {
@ -685,8 +681,7 @@ export default {
} }
}, },
onBlur() { onBlur() {
this.turnOffIdleTimer(); this.typingIndicator.stop();
this.resetTyping();
this.$emit('blur'); this.$emit('blur');
}, },
onFocus() { onFocus() {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,10 @@
<template> <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 }"> <template slot-scope="{ item }">
<span class="text-capitalize variable--list-label"> <span class="text-capitalize variable--list-label">
{{ item.description }} {{ item.description }}
@ -10,6 +15,7 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages'; import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import MentionBox from '../mentions/MentionBox.vue'; import MentionBox from '../mentions/MentionBox.vue';
@ -22,7 +28,16 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
items() { items() {
return [
...this.standardAttributeVariables,
...this.customAttributeVariables,
];
},
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => { return MESSAGE_VARIABLES.filter(variable => {
return ( return (
variable.label.includes(this.searchKey) || variable.label.includes(this.searchKey) ||
@ -34,6 +49,20 @@ export default {
description: variable.label, 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: { methods: {
handleVariableClick(item = {}) { handleVariableClick(item = {}) {

View File

@ -1,20 +1,40 @@
<template> <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"> <ul class="vertical dropdown menu">
<woot-dropdown-item <woot-dropdown-item
v-for="(item, index) in items" v-for="(item, index) in items"
:id="`mention-item-${index}`" :id="`mention-item-${index}`"
:key="item.key" :key="item.key"
class="!mb-0"
@mouseover="onHover(index)" @mouseover="onHover(index)"
> >
<woot-button <button
class="canned-item__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"
:variant="index === selectedIndex ? '' : 'clear'" :class="{
:class="{ active: index === selectedIndex }" ' bg-woot-25 dark:bg-woot-800': index === selectedIndex,
}"
@click="onListItemSelection(index)" @click="onListItemSelection(index)"
> >
<strong>{{ item.label }}</strong> - {{ item.description }} <p
</woot-button> 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> </woot-dropdown-item>
</ul> </ul>
</div> </div>
@ -29,6 +49,10 @@ export default {
type: Array, type: Array,
default: () => {}, default: () => {},
}, },
type: {
type: String,
default: 'canned',
},
}, },
data() { data() {
return { return {
@ -73,28 +97,17 @@ export default {
onSelect() { onSelect() {
this.$emit('mention-select', this.items[this.selectedIndex]); this.$emit('mention-select', this.items[this.selectedIndex]);
}, },
variableKey(item = {}) {
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
},
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.mention--box { .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 > button {
@apply border-0;
.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];
} }
} }

View File

@ -10,6 +10,7 @@ export class DataManager {
async initDb() { async initDb() {
if (this.db) return this.db; if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, { this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) { upgrade(db) {
db.createObjectStore('cache-keys'); 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; return this.db;
} }

View File

@ -41,6 +41,9 @@
"SAVE_CONTACT": "Save", "SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...", "UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story", "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", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response", "NO_RESPONSE": "No response",

View File

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

View File

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

View File

@ -10,6 +10,17 @@ describe('#messageStamp', () => {
}); });
describe('#messageTimestamp', () => { 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', () => { 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( expect(TimeMixin.methods.messageTimestamp(1680777464)).toEqual(
'Apr 6, 2023' 'Apr 6, 2023'

View File

@ -1,6 +1,6 @@
<template> <template>
<div <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"> <div class="flex items-center">
<woot-sidemenu-icon /> <woot-sidemenu-icon />
@ -86,6 +86,45 @@
size="small" size="small"
color-scheme="secondary" 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 <woot-button
size="small" size="small"
icon="add" icon="add"
@ -103,12 +142,15 @@ import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.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'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
export default { export default {
components: { components: {
FluentIcon, FluentIcon,
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
MultiselectDropdownItems,
}, },
mixins: [clickaway], mixins: [clickaway],
props: { props: {
@ -124,16 +166,35 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
selectedLocale: {
type: String,
default: '',
},
shouldShowSettings: { shouldShowSettings: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
allLocales: {
type: Array,
default: () => [],
},
}, },
data() { data() {
return { return {
showSortByDropdown: false, showSortByDropdown: false,
showLocaleDropdown: false,
}; };
}, },
computed: {
shouldShowLocaleDropdown() {
return this.allLocales.length > 1;
},
switchableLocales() {
return this.allLocales.filter(
locale => locale.name !== this.selectedLocale
);
},
},
methods: { methods: {
openFilterModal() { openFilterModal() {
this.$emit('openModal'); this.$emit('openModal');
@ -146,8 +207,22 @@ export default {
this.$emit('close'); this.$emit('close');
this.showSortByDropdown = false; this.showSortByDropdown = false;
}, },
openLocaleDropdown() {
this.showLocaleDropdown = true;
},
closeLocaleDropdown() {
this.showLocaleDropdown = false;
},
onClickNewArticlePage() { 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"> <style scoped lang="scss">
.dropdown-pane--open { .dropdown-pane--open {
@apply top-12 right-[9.25rem]; @apply absolute top-10 right-0 z-50 min-w-[8rem];
} }
.dropdown-arrow { .dropdown-arrow {
@apply ml-1 rtl:ml-0 rtl:mr-1; @apply ml-1 rtl:ml-0 rtl:mr-1;

View File

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

View File

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

View File

@ -50,6 +50,7 @@
:is-loading="uiFlags.isFetching" :is-loading="uiFlags.isFetching"
:on-click-notification="openConversation" :on-click-notification="openConversation"
:in-last-page="inLastPage" :in-last-page="inLastPage"
@close="closeNotificationPanel"
/> />
<div <div
v-if="records.length !== 0" 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> <template>
<div class="flex-col py-2 px-2.5 overflow-auto h-full flex"> <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-for="notificationItem in notifications"
v-show="!isLoading" v-show="!isLoading"
:key="notificationItem.id" :key="notificationItem.id"
size="expanded" :notification-item="notificationItem"
color-scheme="secondary" @open-notification="onClickNotification"
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>
<empty-state <empty-state
v-if="showEmptyResult" v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')" :title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
@ -94,18 +36,16 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue'; import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import timeMixin from '../../../../mixins/time'; import NotificationPanelItem from './NotificationPanelItem.vue';
export default { export default {
components: { components: {
Thumbnail, NotificationPanelItem,
Spinner, Spinner,
EmptyState, EmptyState,
}, },
mixins: [timeMixin],
props: { props: {
notifications: { notifications: {
type: Array, type: Array,
@ -139,9 +79,7 @@ export default {
name: 'notifications_index', name: 'notifications_index',
}); });
} }
}, this.$emit('close');
hasAssignee(notification) {
return notification.primary_actor.meta?.assignee;
}, },
}, },
}; };

View File

@ -108,6 +108,7 @@ const sortConfig = {
}; };
export const sortComparator = (a, b, sortKey) => { 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); 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 commonHelpers from '../../../../helper/commons';
import getters from '../../conversations/getters'; 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 // loads .last() helper
commonHelpers(); commonHelpers();
describe('#getters', () => { describe('#getters', () => {
describe('#getAllConversations', () => { describe('#getAllConversations', () => {
it('order conversations based on last activity', () => { it('returns conversations ordered by lastActivityAt in descending order if no sort order is available', () => {
const state = { const state = { allConversations: [...conversations] };
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,
},
],
};
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[1],
id: 1, conversations[2],
messages: [ conversations[3],
{ conversations[0],
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,
},
]); ]);
}); });
it('order conversations based on created at', () => { it('returns conversations ordered by lastActivityAt in descending order if invalid sort order is available', () => {
const state = { const state = {
allConversations: [ allConversations: [...conversations],
{ chatSortFilter: 'latest',
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',
}; };
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[1],
id: 2, conversations[2],
messages: [{ content: 'test2' }], conversations[3],
created_at: 1652109801, conversations[0],
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
]); ]);
}); });
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 = { const state = {
allConversations: [ allConversations: [...conversations],
{ chatSortFilter: 'last_activity_at_desc',
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',
}; };
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[1],
id: 1, conversations[2],
messages: [ conversations[3],
{ conversations[0],
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
]); ]);
}); });
it('order conversations based on default order', () => { it('returns conversations ordered by lastActivityAt in ascending order if chatStatusFilter = last_activity_at_asc', () => {
const state = { const state = {
allConversations: [ allConversations: [...conversations],
{ chatSortFilter: 'last_activity_at_asc',
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
}; };
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[0],
id: 1, conversations[3],
messages: [ conversations[2],
{ conversations[1],
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,
},
]); ]);
}); });
it('order conversations based on with descending order', () => { it('returns conversations ordered by createdAt in descending order if chatStatusFilter = created_at_desc', () => {
const state = { const state = {
allConversations: [ allConversations: [...conversations],
{ chatSortFilter: 'created_at_desc',
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',
}; };
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[0],
id: 1, conversations[1],
messages: [ conversations[2],
{ conversations[3],
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,
},
]); ]);
}); });
it('order conversations based on waiting_since', () => { it('returns conversations ordered by createdAt in ascending order if chatStatusFilter = created_at_asc', () => {
const state = { const state = {
allConversations: [ allConversations: [...conversations],
{ chatSortFilter: 'created_at_asc',
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',
}; };
expect(getters.getAllConversations(state)).toEqual([ expect(getters.getAllConversations(state)).toEqual([
{ conversations[3],
id: 2, conversations[2],
created_at: 1683645803, conversations[1],
waiting_since: 1683645800, conversations[0],
}, ]);
{ });
id: 1,
created_at: 1683645801, it('returns conversations ordered by priority in descending order if chatStatusFilter = priority_desc', () => {
waiting_since: 1683645802, const state = {
}, allConversations: [...conversations],
{ chatSortFilter: 'priority_desc',
id: 4, };
created_at: 1683645799, expect(getters.getAllConversations(state)).toEqual([
waiting_since: 0, conversations[3],
}, conversations[0],
{ conversations[1],
id: 3, conversations[2],
created_at: 1683645800, ]);
waiting_since: 0, });
},
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 () => { export const deleteIndexedDBOnLogout = async () => {
const dbs = await window.indexedDB.databases(); let dbs = [];
dbs.forEach(db => { try {
window.indexedDB.deleteDatabase(db.name); 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 = () => { export const clearCookiesOnLogout = () => {

View File

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

View File

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

View File

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

View File

@ -19,11 +19,7 @@
<reply-to-chip :reply-to="replyTo" /> <reply-to-chip :reply-to="replyTo" />
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<drag-wrapper <div class="space-y-2">
class="space-y-2"
direction="right"
@dragged="toggleReply"
>
<AgentMessageBubble <AgentMessageBubble
v-if="shouldDisplayAgentMessage" v-if="shouldDisplayAgentMessage"
:content-type="contentType" :content-type="contentType"
@ -54,7 +50,7 @@
<file-bubble v-else :url="attachment.data_url" /> <file-bubble v-else :url="attachment.data_url" />
</div> </div>
</div> </div>
</drag-wrapper> </div>
<div class="flex flex-col justify-end"> <div class="flex flex-col justify-end">
<message-reply-button <message-reply-button
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0" 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 { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js'; import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue'; import ReplyToChip from 'widget/components/ReplyToChip.vue';
import DragWrapper from 'widget/components/DragWrapper.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
export default { export default {
@ -109,7 +104,6 @@ export default {
FileBubble, FileBubble,
MessageReplyButton, MessageReplyButton,
ReplyToChip, ReplyToChip,
DragWrapper,
}, },
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin], mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: { 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 class SendOnSlackJob < MutexApplicationJob
queue_as :medium queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 6 retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook) def perform(message, hook)
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do 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 # Job to reopen snoozed conversations
Conversations::ReopenSnoozedConversationsJob.perform_later Conversations::ReopenSnoozedConversationsJob.perform_later
# Job to reopen snoozed notifications
Notification::ReopenSnoozedNotificationsJob.perform_later
# Job to auto-resolve conversations # Job to auto-resolve conversations
Account::ConversationsResolutionSchedulerJob.perform_later Account::ConversationsResolutionSchedulerJob.perform_later

View File

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

View File

@ -7,11 +7,19 @@ class SupportMailbox < ApplicationMailbox
:decorate_mail :decorate_mail
def process 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 # to turn off spam conversation creation
return unless @account.active? return unless @account.active?
# prevent loop from chatwoot notification emails # prevent loop from chatwoot notification emails
return if notification_email_from_chatwoot? 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 ActiveRecord::Base.transaction do
find_or_create_contact find_or_create_contact
find_or_create_conversation find_or_create_conversation
@ -56,6 +64,10 @@ class SupportMailbox < ApplicationMailbox
mail['In-Reply-To'].try(:value) mail['In-Reply-To'].try(:value)
end end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def find_or_create_conversation def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({ @conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
account_id: @account.id, account_id: @account.id,
@ -74,7 +86,7 @@ class SupportMailbox < ApplicationMailbox
end end
def find_or_create_contact 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? if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact) @contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else 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 # [: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, store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error, :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 store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@ -299,6 +299,10 @@ class Message < ApplicationRecord
end end
def dispatch_update_event 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, Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes) previous_changes: previous_changes)
end end

View File

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

View File

@ -21,7 +21,7 @@ class InboxPolicy < ApplicationPolicy
def show? def show?
# FIXME: for agent bots, lets bring this validation to policies as well in future # 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 Current.user.assigned_inboxes.include? record
end end

View File

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

View File

@ -100,6 +100,12 @@ class Telegram::IncomingMessageService
def attach_files def attach_files
return unless file 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( attachment_file = Down.download(
inbox.channel.get_telegram_file_path(file[:file_id]) inbox.channel.get_telegram_file_path(file[:file_id])
) )

View File

@ -107,11 +107,9 @@ class Twilio::IncomingMessageService
def attach_files def attach_files
return if params[:MediaUrl0].blank? return if params[:MediaUrl0].blank?
attachment_file = Down.download( attachment_file = download_attachment_file
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging return if attachment_file.blank?
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
attachment = @message.attachments.new( attachment = @message.attachments.new(
account_id: @message.account_id, account_id: @message.account_id,
@ -126,4 +124,29 @@ class Twilio::IncomingMessageService
@message.save! @message.save!
end 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 end

View File

@ -8,13 +8,26 @@
</header> </header>
<section class="main-content__body"> <section class="main-content__body">
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %> <%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |c| %> <% @allowed_configs.each do |key| %>
<div class="field-unit"> <div class="flex mb-8">
<div class="field-unit__label"> <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>
<div class="field-unit__field"> <div class="field-unit__field -mt-2 ">
<%= form.text_field "app_config[#{c}]", value: @app_config[c] %> <% 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>
</div> </div>
<% end %> <% end %>

View File

@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '3.4.0' version: '3.5.0'
development: development:
<<: *shared <<: *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 # which means the particular config will be locked
- name: INSTALLATION_NAME - name: INSTALLATION_NAME
value: 'Chatwoot' value: 'Chatwoot'
display_title: 'Installation Name'
description: 'The installation wide name that would be used in the dashboard, title etc.'
- name: LOGO_THUMBNAIL - name: LOGO_THUMBNAIL
value: '/brand-assets/logo_thumbnail.svg' value: '/brand-assets/logo_thumbnail.svg'
display_title: 'Logo Thumbnail'
description: 'The thumbnail that would be used for favicon (512px X 512px)'
- name: LOGO - name: LOGO
value: '/brand-assets/logo.svg' 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 - name: BRAND_URL
value: 'https://www.chatwoot.com' 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 - name: WIDGET_BRAND_URL
value: 'https://www.chatwoot.com' 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 - name: BRAND_NAME
value: 'Chatwoot' value: 'Chatwoot'
display_title: 'Brand Name'
description: 'The name that would be used in emails and the widget'
- name: TERMS_URL - name: TERMS_URL
value: 'https://www.chatwoot.com/terms-of-service' 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 - name: PRIVACY_URL
value: 'https://www.chatwoot.com/privacy-policy' value: 'https://www.chatwoot.com/privacy-policy'
display_title: 'Privacy URL'
description: 'The privacy policy URL displayed in the app'
- name: DISPLAY_MANIFEST - name: DISPLAY_MANIFEST
value: true value: true
display_title: 'Chatwoot Metadata'
description: 'Display default Chatwoot metadata like favicons and upgrade warnings'
type: boolean
- name: MAILER_INBOUND_EMAIL_DOMAIN - name: MAILER_INBOUND_EMAIL_DOMAIN
value: value:
locked: false locked: false
@ -72,8 +95,6 @@
value: self-hosted value: self-hosted
- name: CSML_EDITOR_HOST - name: CSML_EDITOR_HOST
value: value:
- name: LOGO_DARK
value: '/brand-assets/logo_dark.svg'
- name: INSTALLATION_PRICING_PLAN - name: INSTALLATION_PRICING_PLAN
value: 'community' value: 'community'
- name: INSTALLATION_PRICING_PLAN_QUANTITY - name: INSTALLATION_PRICING_PLAN_QUANTITY

View File

@ -29,8 +29,8 @@ common: &default_settings
# independently. If `false`, all logging-related features are disabled. # independently. If `false`, all logging-related features are disabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_ENABLED', false) %> enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_ENABLED', false) %>
forwarding: forwarding:
# If `true`, the agent captures log records emitted by this application. # If `true`, the agent captures log records emitted by this application
enabled: true 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. # Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 30000 max_samples_stored: 30000
metrics: metrics:
@ -40,7 +40,7 @@ common: &default_settings
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans. # 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 requires a log forwarder to send your log files to New Relic.
# This should not be used when forwarding is enabled. # 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. # 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "snoozed_until" t.datetime "snoozed_until"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.index ["account_id"], name: "index_notifications_on_account_id" 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 ["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 ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
t.index ["user_id"], name: "index_notifications_on_user_id" t.index ["user_id"], name: "index_notifications_on_user_id"

View File

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

View File

@ -21,6 +21,9 @@ module Enterprise::Channelable
return if audited_changes.blank? 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( Enterprise::AuditLog.create(
auditable_id: auditable_id, auditable_id: auditable_id,
auditable_type: auditable_type, auditable_type: auditable_type,
@ -30,5 +33,13 @@ module Enterprise::Channelable
audited_changes: audited_changes audited_changes: audited_changes
) )
end 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
end end

View File

@ -14,7 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
private private
def process_subscription_updated 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 # skipping self hosted plan events
return if plan.blank? || account.blank? return if plan.blank? || account.blank?

View File

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

View File

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

View File

@ -12,6 +12,7 @@ class GlobalConfig
config[config_key] = load_from_cache(config_key) config[config_key] = load_from_cache(config_key)
end end
typecast_config(config)
config.with_indifferent_access config.with_indifferent_access
end end
@ -28,6 +29,14 @@ class GlobalConfig
private 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) def load_from_cache(config_key)
cache_key = "#{VERSION}:#{KEY_PREFIX}:#{config_key}" cache_key = "#{VERSION}:#{KEY_PREFIX}:#{config_key}"
cached_value = $alfred.with { |conn| conn.get(cache_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? post_message if message_content.present?
upload_file if message.attachments.any? upload_file if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth, 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 Rails.logger.error e
hook.prompt_reauthorization! hook.prompt_reauthorization!
hook.disable hook.disable

View File

@ -12,10 +12,10 @@
# end # end
# #
class Redis::LockManager class Redis::LockManager
# Default lock timeout set to 2 seconds. This means that if the lock isn't released # Default lock timeout set to 1 second. This means that if the lock isn't released
# within 2 seconds, it will automatically expire. # 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. # 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. # Attempts to acquire a lock for the given key.
# #

View File

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

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 describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_status' do
let(:conversation) { create(:conversation, account: account) } 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 context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@ -424,6 +427,42 @@ RSpec.describe 'Conversations API', type: :request do
# expect(conversation.reload.status).to eq('pending') # expect(conversation.reload.status).to eq('pending')
# end # end
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 end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_priority' do 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 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 end

View File

@ -273,6 +273,33 @@ FactoryBot.define do
initialize_with { attributes } initialize_with { attributes }
end 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 factory :messaging_seen_event, class: Hash do
entry 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 described_class.perform_now
end end
it 'triggers Notification::ReopenSnoozedNotificationsJob' do
expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Account::ConversationsResolutionSchedulerJob' do it 'triggers Account::ConversationsResolutionSchedulerJob' do
expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once
described_class.perform_now 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_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!(: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!(: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 } let(:fb_object) { double }
describe '#perform' do 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.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1 expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end end
it 'creates standby message in the instagram inbox' do it 'creates standby message in the instagram inbox' do
@ -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 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]) instagram_webhook.perform_now(messaging_seen_event[:entry])
end 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 end
end end

View File

@ -42,6 +42,11 @@ describe Redis::Config do
end end
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 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.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}") expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@ -59,6 +64,11 @@ describe Redis::Config do
end end
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 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.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}") expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@ -77,6 +87,11 @@ describe Redis::Config do
end end
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 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.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}") expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")

View File

@ -16,6 +16,17 @@ RSpec.describe SupportMailbox do
end end
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 describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) } let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) } let(:agent) { create(:user, email: 'agent1@example.com', account: account) }

View File

@ -39,5 +39,10 @@ describe ActionService do
action_service.assign_agent(['nil']) action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_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
end
end end
end end

View File

@ -215,6 +215,26 @@ describe Telegram::IncomingMessageService do
end end
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 context 'when valid location message params' do
it 'creates appropriate conversations, message and contacts' do it 'creates appropriate conversations, message and contacts' do
params = { params = {

View File

@ -169,5 +169,65 @@ describe Twilio::IncomingMessageService do
expect(twilio_sms_channel.inbox.conversations.count).to eq(1) expect(twilio_sms_channel.inbox.conversations.count).to eq(1)
end end
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
end end

View File

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