Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7adf7ab115 | ||
|
|
ba1eaa169a | ||
|
|
a862ddfafe | ||
|
|
249fcdf471 | ||
|
|
e4620291a7 | ||
|
|
3aa79dffef | ||
|
|
758b7bf061 | ||
|
|
5c9ab21617 | ||
|
|
93d126af7b | ||
|
|
3c6c57ca0e | ||
|
|
b952608eeb | ||
|
|
eb6f1e4031 | ||
|
|
94909255a8 | ||
|
|
91788255f2 | ||
|
|
515b9ba0b5 | ||
|
|
e5c5ebc398 | ||
|
|
21212d9bc3 | ||
|
|
0af9e974bd | ||
|
|
b3141de50b | ||
|
|
1da6962467 | ||
|
|
b71a580573 | ||
|
|
6a73953003 | ||
|
|
336af1ac9a | ||
|
|
e4de366b8d | ||
|
|
373c3dcd6c | ||
|
|
4b12dd89b0 | ||
|
|
c19cfeaa81 | ||
|
|
fa118ad18c | ||
|
|
249bfe44bb | ||
|
|
826d9ec5a7 | ||
|
|
d8b53f5d2f | ||
|
|
08ced452e1 | ||
|
|
24fe3805d8 | ||
|
|
845b0c0a68 |
+1
-1
@@ -199,7 +199,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## Rack Attack configuration
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_IP_LIMIT=3000
|
||||
# RACK_ATTACK_LIMIT=300
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
|
||||
@@ -58,6 +58,7 @@ Metrics/BlockLength:
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/seeders/message_seeder.rb
|
||||
- spec/support/slack_stubs.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
|
||||
@@ -159,7 +159,7 @@ gem 'lograge', '~> 0.13.0', require: false
|
||||
# worked with microsoft refresh token
|
||||
gem 'omniauth-oauth2'
|
||||
|
||||
gem 'audited', '~> 5.3'
|
||||
gem 'audited', '~> 5.4', '>= 5.4.0'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth'
|
||||
|
||||
+3
-3
@@ -126,8 +126,8 @@ GEM
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.2)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.3.3)
|
||||
activerecord (>= 5.0, < 7.1)
|
||||
audited (5.4.0)
|
||||
activerecord (>= 5.0, < 7.2)
|
||||
request_store (~> 1.2)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.760.0)
|
||||
@@ -839,7 +839,7 @@ DEPENDENCIES
|
||||
administrate-field-belongs_to_search
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.3)
|
||||
audited (~> 5.4, >= 5.4.0)
|
||||
aws-sdk-s3
|
||||
azure-storage-blob!
|
||||
barnes
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
class Api::V1::Integrations::WebhooksController < ApplicationController
|
||||
def create
|
||||
builder = Integrations::Slack::IncomingMessageBuilder.new(params)
|
||||
builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params)
|
||||
response = builder.perform
|
||||
render json: response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# TODO: This is a temporary solution to permit all params for slack unfurling job.
|
||||
# We should only permit the params that we use. Handle all the params based on events and send it to the respective services.
|
||||
def permitted_params
|
||||
params.permit!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,11 +9,14 @@ module RequestExceptionHandler
|
||||
|
||||
def handle_with_exception
|
||||
yield
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
log_handled_error(e)
|
||||
render_not_found_error('Resource could not be found')
|
||||
rescue Pundit::NotAuthorizedError
|
||||
rescue Pundit::NotAuthorizedError => e
|
||||
log_handled_error(e)
|
||||
render_unauthorized('You are not authorized to do this action')
|
||||
rescue ActionController::ParameterMissing => e
|
||||
log_handled_error(e)
|
||||
render_could_not_create_error(e.message)
|
||||
ensure
|
||||
# to address the thread variable leak issues in Puma/Thin webserver
|
||||
@@ -41,6 +44,7 @@ module RequestExceptionHandler
|
||||
end
|
||||
|
||||
def render_record_invalid(exception)
|
||||
log_handled_error(exception)
|
||||
render json: {
|
||||
message: exception.record.errors.full_messages.join(', '),
|
||||
attributes: exception.record.errors.attribute_names
|
||||
@@ -48,6 +52,11 @@ module RequestExceptionHandler
|
||||
end
|
||||
|
||||
def render_error_response(exception)
|
||||
log_handled_error(exception)
|
||||
render json: exception.to_hash, status: exception.http_status
|
||||
end
|
||||
|
||||
def log_handled_error(exception)
|
||||
logger.info("Handled error: #{exception.inspect}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +33,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
::Conversation.create!(conversation_params)
|
||||
ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
@@ -41,11 +41,6 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @contact_inbox.contact.account_id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
}
|
||||
params.permit(custom_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Public::Api::V1::Portals::BaseController < PublicController
|
||||
before_action :show_plain_layout
|
||||
before_action :set_color_scheme
|
||||
around_action :set_locale
|
||||
after_action :allow_iframe_requests
|
||||
|
||||
@@ -9,6 +10,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
@is_plain_layout_enabled = params[:show_plain_layout] == 'true'
|
||||
end
|
||||
|
||||
def set_color_scheme
|
||||
@theme = if %w[dark light].include?(params[:theme])
|
||||
params[:theme]
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def set_locale(&)
|
||||
switch_locale_with_portal(&) if params[:locale].present?
|
||||
switch_locale_with_article(&) if params[:article_slug].present?
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
module PortalHelper
|
||||
def generate_portal_bg_color(portal_color, theme)
|
||||
base_color = theme == 'dark' ? 'black' : 'white'
|
||||
"color-mix(in srgb, #{portal_color} 10%, #{base_color})"
|
||||
end
|
||||
|
||||
def generate_portal_bg(portal_color, theme)
|
||||
bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg'
|
||||
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
|
||||
end
|
||||
end
|
||||
@@ -149,15 +149,11 @@
|
||||
}
|
||||
|
||||
.multiselect-wrap--small {
|
||||
.multiselect__tags,
|
||||
.multiselect__input {
|
||||
@apply items-center flex;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm h-10 min-h-[2.5rem];
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@@ -165,11 +161,15 @@
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-1 px-0.5;
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply m-0 py-1 px-0.5;
|
||||
@apply m-0 py-2 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply py-[6px] my-[1px];
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@@ -181,9 +181,6 @@
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-wrap--medium {
|
||||
|
||||
@@ -53,16 +53,24 @@ export default {
|
||||
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
|
||||
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
return !isBeforeAMonth
|
||||
? `Created ${this.createdAtTimeAgo}`
|
||||
: `Created at: ${this.dateFormat(this.createdAtTimestamp)}`;
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
|
||||
this.createdAtTimeAgo
|
||||
}`
|
||||
: `${this.$t(
|
||||
'CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST'
|
||||
)} ${this.dateFormat(this.createdAtTimestamp)}`;
|
||||
},
|
||||
lastActivity() {
|
||||
const lastActivityTimeDiff =
|
||||
Date.now() - this.lastActivityTimestamp * 1000;
|
||||
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
return !isNotActive
|
||||
? `Last activity ${this.lastActivityAtTimeAgo}`
|
||||
: `Last activity: ${this.dateFormat(this.lastActivityTimestamp)}`;
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
|
||||
this.lastActivityAtTimeAgo
|
||||
}`
|
||||
: `${this.$t(
|
||||
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
|
||||
)} ${this.dateFormat(this.lastActivityTimestamp)}`;
|
||||
},
|
||||
tooltipText() {
|
||||
return `${this.createdAt}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -711,6 +712,6 @@ export default {
|
||||
}
|
||||
|
||||
.editor-warning__message {
|
||||
@apply text-red-400 dark:text-red-400 font-normal pt-1 pb-0 px-0;
|
||||
@apply text-red-400 dark:text-red-400 text-sm font-normal pt-1 pb-0 px-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
:message="data"
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@replyTo="handleReplyTo"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -188,6 +189,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -269,6 +274,8 @@ export default {
|
||||
copy: this.hasText,
|
||||
delete: this.hasText || this.hasAttachments,
|
||||
cannedResponse: this.isOutgoing && this.hasText,
|
||||
replyTo: false,
|
||||
// replyTo: !this.data.private && this.inboxSupportsReplyTo,
|
||||
};
|
||||
},
|
||||
contentAttributes() {
|
||||
@@ -494,6 +501,12 @@ export default {
|
||||
this.showContextMenu = false;
|
||||
this.contextMenuPosition = { x: null, y: null };
|
||||
},
|
||||
handleReplyTo() {
|
||||
this.$store.dispatch('draftMessages/setInReplyTo', {
|
||||
conversationId: this.data.conversation_id,
|
||||
inReplyToMessage: this.data.id,
|
||||
});
|
||||
},
|
||||
setupHighlightTimer() {
|
||||
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
|
||||
return;
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto"
|
||||
>
|
||||
<p class="w-fit !m-0">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="link"
|
||||
@click="openProfileSettings"
|
||||
>
|
||||
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
||||
</woot-button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
openProfileSettings() {
|
||||
return this.$router.push({ name: 'profile_settings_index' });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -42,6 +42,7 @@
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
/>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
@@ -110,7 +111,7 @@ import { mapGetters } from 'vuex';
|
||||
import conversationMixin, {
|
||||
filterDuplicateSourceMessages,
|
||||
} from '../../../mixins/conversations';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
@@ -316,6 +317,9 @@ export default {
|
||||
unreadMessageCount() {
|
||||
return this.currentChat.unread_count || 0;
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
return this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@@ -90,17 +90,9 @@
|
||||
:remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
<message-signature-missing-alert
|
||||
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||
class="message-signature-wrap"
|
||||
>
|
||||
<p class="message-signature">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
<router-link :to="profilePath">
|
||||
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
/>
|
||||
<reply-bottom-panel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
@@ -154,6 +146,7 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
@@ -171,7 +164,6 @@ import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
@@ -200,6 +192,7 @@ export default {
|
||||
WootAudioRecorder,
|
||||
Banner,
|
||||
WhatsappTemplates,
|
||||
MessageSignatureMissingAlert,
|
||||
},
|
||||
mixins: [
|
||||
clickaway,
|
||||
@@ -480,9 +473,6 @@ export default {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
},
|
||||
profilePath() {
|
||||
return frontendURL(`accounts/${this.accountId}/profile/settings`);
|
||||
},
|
||||
editorMessageKey() {
|
||||
const { editor_message_key: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
@@ -565,7 +555,7 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.getFromDraft();
|
||||
// Donot use the keyboard listener mixin here as the events here are supposed to be
|
||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||
// working even if input/textarea is focussed.
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
@@ -1083,14 +1073,6 @@ export default {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.message-signature-wrap {
|
||||
@apply my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto;
|
||||
}
|
||||
|
||||
.message-signature {
|
||||
@apply w-fit m-0;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
@@ -1136,13 +1118,6 @@ export default {
|
||||
@apply left-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
.message-signature {
|
||||
@apply mb-0;
|
||||
|
||||
::v-deep p:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * var(--space-normal));
|
||||
|
||||
@@ -2,6 +2,7 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
MESSAGE_IN_REPLY_TO: 'messageInReplyTo',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
};
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
"STATUS": "Status",
|
||||
"ORDER_BY": "Order by"
|
||||
},
|
||||
"CHAT_TIME_STAMP": {
|
||||
"CREATED": {
|
||||
"LATEST": "Created",
|
||||
"OLDEST": "Created at:"
|
||||
},
|
||||
"LAST_ACTIVITY": {
|
||||
"NOT_ACTIVE": "Last activity:",
|
||||
"ACTIVE": "Last activity"
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_FILTER_ITEMS": {
|
||||
"latest": {
|
||||
"TEXT": "Last activity"
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
},
|
||||
"CONTEXT_MENU": {
|
||||
"COPY": "Copy",
|
||||
"REPLY_TO": "Reply to this message",
|
||||
"DELETE": "Delete",
|
||||
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
|
||||
"TRANSLATE": "Translate",
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="menu-container">
|
||||
<menu-item
|
||||
v-if="enabledOptions['replyTo']"
|
||||
:option="{
|
||||
icon: 'arrow-reply',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPLY_TO'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click="handleReplyTo"
|
||||
/>
|
||||
<menu-item
|
||||
v-if="enabledOptions['copy']"
|
||||
:option="{
|
||||
@@ -204,10 +213,13 @@ export default {
|
||||
this.handleClose();
|
||||
this.showTranslateModal = true;
|
||||
},
|
||||
handleReplyTo() {
|
||||
this.$emit('replyTo', this.message);
|
||||
this.handleClose();
|
||||
},
|
||||
onCloseTranslateModal() {
|
||||
this.showTranslateModal = false;
|
||||
},
|
||||
|
||||
openDeleteModal() {
|
||||
this.handleClose();
|
||||
this.showDeleteModal = true;
|
||||
|
||||
+84
-20
@@ -99,26 +99,46 @@
|
||||
<div v-if="isEmailOrWebWidgetInbox">
|
||||
<label>
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
|
||||
<reply-email-head
|
||||
v-if="isAnEmailInbox"
|
||||
:cc-emails.sync="ccEmails"
|
||||
:bcc-emails.sync="bccEmails"
|
||||
/>
|
||||
<label class="editor-wrap">
|
||||
<woot-message-editor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.message.$error }"
|
||||
:enable-variables="true"
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@blur="$v.message.$touch"
|
||||
/>
|
||||
<span v-if="$v.message.$error" class="editor-warning__message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
<reply-email-head
|
||||
v-if="isAnEmailInbox"
|
||||
:cc-emails.sync="ccEmails"
|
||||
:bcc-emails.sync="bccEmails"
|
||||
/>
|
||||
<div class="editor-wrap">
|
||||
<woot-message-editor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.message.$error }"
|
||||
:enable-variables="true"
|
||||
:signature="signatureToApply"
|
||||
:allow-signature="true"
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@blur="$v.message.$touch"
|
||||
>
|
||||
<template #footer>
|
||||
<message-signature-missing-alert
|
||||
v-if="isSignatureEnabledForInbox && !messageSignature"
|
||||
class="!mx-0 mb-1"
|
||||
/>
|
||||
<div v-if="isAnEmailInbox" class="mb-3 mt-px">
|
||||
<woot-button
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="signature"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="signatureToggleTooltip"
|
||||
@click.prevent="toggleMessageSignature"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</woot-message-editor>
|
||||
<span v-if="$v.message.$error" class="editor-warning__message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<whatsapp-templates
|
||||
v-else-if="hasWhatsappTemplates"
|
||||
@@ -162,6 +182,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert';
|
||||
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
|
||||
import WhatsappTemplates from './WhatsappTemplates.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
@@ -169,6 +190,11 @@ import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { getInboxSource } from 'dashboard/helper/inbox';
|
||||
import { required, requiredIf } from 'vuelidate/lib/validators';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -178,8 +204,9 @@ export default {
|
||||
CannedResponse,
|
||||
WhatsappTemplates,
|
||||
InboxDropdownItem,
|
||||
MessageSignatureMissingAlert,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
mixins: [alertMixin, uiSettingsMixin],
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
@@ -219,7 +246,15 @@ export default {
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
conversationsUiFlags: 'contactConversations/getUIFlags',
|
||||
currentUser: 'getCurrentUser',
|
||||
messageSignature: 'getMessageSignature',
|
||||
}),
|
||||
sendWithSignature() {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
},
|
||||
signatureToApply() {
|
||||
return this.messageSignature;
|
||||
},
|
||||
emailMessagePayload() {
|
||||
const payload = {
|
||||
inboxId: this.targetInbox.id,
|
||||
@@ -259,6 +294,14 @@ export default {
|
||||
}
|
||||
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
||||
},
|
||||
isSignatureEnabledForInbox() {
|
||||
return this.isAnEmailInbox && this.sendWithSignature;
|
||||
},
|
||||
signatureToggleTooltip() {
|
||||
return this.sendWithSignature
|
||||
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
inboxes() {
|
||||
const inboxList = this.contact.contactableInboxes || [];
|
||||
return inboxList.map(inbox => ({
|
||||
@@ -298,8 +341,23 @@ export default {
|
||||
this.showCannedResponseMenu = false;
|
||||
}
|
||||
},
|
||||
targetInbox() {
|
||||
this.setSignature();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setSignature();
|
||||
},
|
||||
methods: {
|
||||
setSignature() {
|
||||
if (this.messageSignature) {
|
||||
if (this.isSignatureEnabledForInbox) {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
} else {
|
||||
this.message = removeSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
@@ -370,6 +428,12 @@ export default {
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
toggleMessageSignature() {
|
||||
this.updateUISettings({
|
||||
send_with_signature: !this.sendWithSignature,
|
||||
});
|
||||
this.setSignature();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -16,19 +16,23 @@
|
||||
@blur="$v.title.$touch"
|
||||
/>
|
||||
|
||||
<label v-if="isOngoingType" class="editor-wrap">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
|
||||
<woot-message-editor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.message.$error }"
|
||||
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@blur="$v.message.$touch"
|
||||
/>
|
||||
<span v-if="$v.message.$error" class="editor-warning__message">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isOngoingType" class="editor-wrap">
|
||||
<label>
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
|
||||
</label>
|
||||
<div>
|
||||
<woot-message-editor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.message.$error }"
|
||||
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@blur="$v.message.$touch"
|
||||
/>
|
||||
<span v-if="$v.message.$error" class="editor-warning__message">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-else :class="{ error: $v.message.$error }">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
|
||||
@blur="$v.title.$touch"
|
||||
/>
|
||||
<label class="editor-wrap">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
|
||||
<div class="editor-wrap">
|
||||
<label>
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
|
||||
</label>
|
||||
<woot-message-editor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
@@ -25,7 +27,7 @@
|
||||
<span v-if="$v.message.$error" class="editor-warning__message">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label :class="{ error: $v.selectedInbox.$error }">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<div v-if="preChatFormEnabled">
|
||||
<label class="w-[70%]">
|
||||
<div class="w-[70%]">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
|
||||
</label>
|
||||
@@ -27,7 +27,7 @@
|
||||
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="w-[70%] mt-4">
|
||||
<label>{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}</label>
|
||||
<table class="table table-striped w-full">
|
||||
|
||||
+7
-5
@@ -15,9 +15,11 @@
|
||||
</label>
|
||||
<p>{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP') }}</p>
|
||||
<div v-if="isBusinessHoursEnabled" class="mb-6">
|
||||
<label class="unavailable-input-wrap">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
|
||||
<label
|
||||
<div class="max-w-[37.5rem]">
|
||||
<label class="unavailable-input-wrap">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
v-if="isRichEditorEnabled"
|
||||
class="py-0 px-4 border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md mx-0 mt-0 mb-4"
|
||||
>
|
||||
@@ -28,9 +30,9 @@
|
||||
class="message-editor"
|
||||
:min-height="4"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea v-else v-model="unavailableMessage" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="timezone-input-wrap">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL') }}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
|
||||
const state = {
|
||||
records: LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {},
|
||||
inReplyTo: LocalStorage.get(LOCAL_STORAGE_KEYS.MESSAGE_IN_REPLY_TO) || {},
|
||||
replyEditorMode: REPLY_EDITOR_MODES.REPLY,
|
||||
};
|
||||
|
||||
@@ -14,6 +15,9 @@ export const getters = {
|
||||
get: _state => key => {
|
||||
return _state.records[key] || '';
|
||||
},
|
||||
getInReplyTo: _state => conversationId => {
|
||||
return _state.inReplyTo[conversationId];
|
||||
},
|
||||
getReplyEditorMode: _state => _state.replyEditorMode,
|
||||
};
|
||||
|
||||
@@ -24,6 +28,12 @@ export const actions = {
|
||||
delete: ({ commit }, { key }) => {
|
||||
commit(types.SET_DRAFT_MESSAGES, { key });
|
||||
},
|
||||
setInReplyTo({ commit }, { conversationId, inReplyToMessage }) {
|
||||
commit(types.SET_IN_REPLY_TO, { conversationId, inReplyToMessage });
|
||||
},
|
||||
deleteInReplyTo({ commit }, { conversationId }) {
|
||||
commit(types.REMOVE_IN_REPLY_TO, { conversationId });
|
||||
},
|
||||
setReplyEditorMode: ({ commit }, { mode }) => {
|
||||
commit(types.SET_REPLY_EDITOR_MODE, { mode });
|
||||
},
|
||||
@@ -39,6 +49,16 @@ export const mutations = {
|
||||
Vue.set($state, 'records', updatedRecords);
|
||||
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.records);
|
||||
},
|
||||
[types.SET_IN_REPLY_TO]($state, { conversationId, inReplyToMessage }) {
|
||||
Vue.set($state.inReplyTo, conversationId, inReplyToMessage);
|
||||
LocalStorage.set(LOCAL_STORAGE_KEYS.MESSAGE_IN_REPLY_TO, $state.inReplyTo);
|
||||
},
|
||||
[types.REMOVE_IN_REPLY_TO]($state, { conversationId }) {
|
||||
const { [conversationId]: messageIdToBeRemoved, ...newRecords } =
|
||||
$state.inReplyTo;
|
||||
Vue.set($state, 'inReplyTo', newRecords);
|
||||
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.inReplyTo);
|
||||
},
|
||||
[types.SET_REPLY_EDITOR_MODE]($state, { mode }) {
|
||||
Vue.set($state, 'replyEditorMode', mode);
|
||||
},
|
||||
|
||||
@@ -73,4 +73,49 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setInReplyTo', () => {
|
||||
it('sends correct actions', async () => {
|
||||
await actions.setInReplyTo(
|
||||
{
|
||||
commit,
|
||||
state: {
|
||||
inReplyTo: {},
|
||||
},
|
||||
},
|
||||
{ conversationId: 45534, inReplyToMessage: 24332 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
types.SET_IN_REPLY_TO,
|
||||
{
|
||||
conversationId: 45534,
|
||||
inReplyToMessage: 24332,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteInReplyTo', () => {
|
||||
it('sends correct actions', async () => {
|
||||
await actions.deleteInReplyTo(
|
||||
{
|
||||
commit,
|
||||
state: {
|
||||
inReplyTo: {},
|
||||
},
|
||||
},
|
||||
{ conversationId: 45534 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
types.REMOVE_IN_REPLY_TO,
|
||||
{
|
||||
conversationId: 45534,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,4 +22,24 @@ describe('#getters', () => {
|
||||
};
|
||||
expect(getters.getReplyEditorMode(state)).toEqual('reply');
|
||||
});
|
||||
|
||||
describe('#getInReplyTo', () => {
|
||||
it('returns the inReplyTo value if key is present', () => {
|
||||
const state = {
|
||||
inReplyTo: {
|
||||
1234: 9876,
|
||||
},
|
||||
};
|
||||
expect(getters.getInReplyTo(state)(1234)).toEqual(9876);
|
||||
});
|
||||
|
||||
it('returns undefined if key is not present', () => {
|
||||
const state = {
|
||||
inReplyTo: {
|
||||
1234: 9876,
|
||||
},
|
||||
};
|
||||
expect(getters.getInReplyTo(state)('message-22')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,35 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_IN_REPLY_TO', () => {
|
||||
it('sets the inReplyTo value', () => {
|
||||
const state = {
|
||||
inReplyTo: {},
|
||||
};
|
||||
mutations[types.SET_IN_REPLY_TO](state, {
|
||||
conversationId: 1234,
|
||||
inReplyToMessage: 9876,
|
||||
});
|
||||
expect(state.inReplyTo).toEqual({
|
||||
1234: 9876,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#REMOVE_IN_REPLY_TO', () => {
|
||||
it('removes the inReplyTo value', () => {
|
||||
const state = {
|
||||
inReplyTo: {
|
||||
1234: 9876,
|
||||
},
|
||||
};
|
||||
mutations[types.REMOVE_IN_REPLY_TO](state, {
|
||||
conversationId: 1234,
|
||||
});
|
||||
expect(state.inReplyTo).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_REPLY_EDITOR_MODE', () => {
|
||||
it('sets the reply editor mode', () => {
|
||||
const state = {
|
||||
|
||||
@@ -28,6 +28,8 @@ export default {
|
||||
SET_DRAFT_MESSAGES: 'SET_DRAFT_MESSAGES',
|
||||
REMOVE_DRAFT_MESSAGES: 'REMOVE_DRAFT_MESSAGES',
|
||||
SET_REPLY_EDITOR_MODE: 'SET_REPLY_EDITOR_MODE',
|
||||
SET_IN_REPLY_TO: 'SET_IN_REPLY_TO',
|
||||
REMOVE_IN_REPLY_TO: 'REMOVE_IN_REPLY_TO',
|
||||
|
||||
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
|
||||
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full flex items-center rounded-md border-solid border-2 h-16 bg-white px-4 py-2 text-slate-600"
|
||||
class="w-full flex items-center rounded-md border-solid border-2 h-16 bg-white dark:bg-slate-900 px-4 py-2 text-slate-600 dark:text-slate-200"
|
||||
:class="{
|
||||
'shadow border-woot-100': isFocused,
|
||||
'border-slate-50 shadow-sm': !isFocused,
|
||||
'shadow border-woot-100 dark:border-woot-700': isFocused,
|
||||
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
|
||||
}"
|
||||
>
|
||||
<fluent-icon icon="search" />
|
||||
<input
|
||||
:value="value"
|
||||
type="text"
|
||||
class="w-full focus:outline-none text-base h-full bg-white px-2 py-2 text-slate-700 placeholder-slate-500"
|
||||
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
|
||||
:placeholder="searchPlaceholder"
|
||||
role="search"
|
||||
@input="onChange"
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-md bg-white mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700"
|
||||
class="shadow-md bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<div v-if="isLoading" class="font-medium text-sm text-slate-400">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="font-medium text-sm text-slate-400 dark:text-slate-700"
|
||||
>
|
||||
{{ loadingPlaceholder }}
|
||||
</div>
|
||||
<h3 v-if="shouldShowResults" class="font-medium text-sm text-slate-400">
|
||||
<h3
|
||||
v-if="shouldShowResults"
|
||||
class="font-medium text-sm text-slate-400 dark:text-slate-700"
|
||||
>
|
||||
{{ resultsTitle }}
|
||||
</h3>
|
||||
<ul
|
||||
v-if="shouldShowResults"
|
||||
class="bg-white mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700"
|
||||
class="bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
@@ -18,7 +24,7 @@
|
||||
:id="article.id"
|
||||
:key="article.id"
|
||||
class="group flex cursor-default select-none items-center rounded-md p-2 mb-1"
|
||||
:class="{ 'bg-slate-25': index === selectedIndex }"
|
||||
:class="{ 'bg-slate-25 dark:bg-slate-800': index === selectedIndex }"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
@mouseover="onHover(index)"
|
||||
@@ -32,7 +38,10 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="showEmptyResults" class="font-medium text-sm text-slate-400">
|
||||
<div
|
||||
v-if="showEmptyResults"
|
||||
class="font-medium text-sm text-slate-400 dark:text-slate-700"
|
||||
>
|
||||
{{ emptyPlaceholder }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<nav class="max-w-2xl">
|
||||
<h2
|
||||
id="on-this-page-title"
|
||||
class="text-slate-800 font-semibold tracking-wide border-b mb-3 leading-7"
|
||||
class="text-slate-800 dark:text-slate-50 font-semibold tracking-wide border-b mb-3 leading-7"
|
||||
>
|
||||
{{ tocHeader }}
|
||||
</h2>
|
||||
@@ -14,7 +14,7 @@
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbolinks="false"
|
||||
class="text-base text-slate-800 cursor-pointer"
|
||||
class="text-base text-slate-800 dark:text-slate-50 cursor-pointer"
|
||||
>
|
||||
{{ element.title }}
|
||||
</a>
|
||||
|
||||
@@ -23,11 +23,7 @@ import {
|
||||
} from './bubbleHelpers';
|
||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||
import {
|
||||
CHATWOOT_ERROR,
|
||||
CHATWOOT_ON_MESSAGE,
|
||||
CHATWOOT_READY,
|
||||
} from '../widget/constants/sdkEvents';
|
||||
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
|
||||
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
|
||||
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
|
||||
import {
|
||||
@@ -191,8 +187,8 @@ export const IFrameHelper = {
|
||||
Cookies.remove(getUserCookieName());
|
||||
}
|
||||
},
|
||||
onMessage({ data }) {
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_ON_MESSAGE, data });
|
||||
onEvent({ eventIdentifier: eventName, data }) {
|
||||
dispatchWindowEvent({ eventName, data });
|
||||
},
|
||||
setBubbleLabel(message) {
|
||||
setBubbleText(window.$chatwoot.launcherTitle || message.label);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<template>
|
||||
<section>
|
||||
<label v-if="richtext" class="greetings--richtext">
|
||||
<section class="w-[75%]">
|
||||
<div
|
||||
v-if="richtext"
|
||||
class="py-0 px-4 rounded-md border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 mt-0 mx-0 mb-4"
|
||||
>
|
||||
<woot-message-editor
|
||||
v-model="greetingsMessage"
|
||||
:is-format-mode="true"
|
||||
:enable-variables="true"
|
||||
class="input"
|
||||
class="input bg-white dark:bg-slate-900"
|
||||
:placeholder="placeholder"
|
||||
:min-height="4"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<resizable-text-area
|
||||
v-else
|
||||
v-model="greetingsMessage"
|
||||
rows="4"
|
||||
type="text"
|
||||
class="medium-9 greetings--textarea"
|
||||
class="greetings--textarea"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
@@ -68,12 +71,3 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.greetings--richtext {
|
||||
padding: 0 var(--space-normal);
|
||||
border-radius: var(--border-radius-normal);
|
||||
border: 1px solid var(--color-border);
|
||||
margin: 0 0 var(--space-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,24 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
};
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
};
|
||||
|
||||
// This is a single source of truth for inbox features
|
||||
// This is used to check if a feature is available for a particular inbox or not
|
||||
export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.LINE,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
channelType() {
|
||||
@@ -102,4 +120,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inboxHasFeature(feature) {
|
||||
return INBOX_FEATURE_MAP[feature]?.includes(this.channelType) ?? false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,218 +1,286 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import inboxMixin from '../inboxMixin';
|
||||
|
||||
function getComponentConfigForInbox(channelType, additionalConfig = {}) {
|
||||
return {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: channelType,
|
||||
...additionalConfig,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getComponentConfigForChat(chat) {
|
||||
return {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
chat,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxMixin', () => {
|
||||
it('returns the correct channel type', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::WebWidget' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::WebWidget');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.channelType).toBe('Channel::WebWidget');
|
||||
});
|
||||
|
||||
it('isAPIInbox returns true if channel type is API', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::Api' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::Api');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAPIInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwitterInbox returns true if channel type is twitter', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::TwitterProfile' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwitterInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isAFacebookInbox returns true if channel type is Facebook', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::FacebookPage' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::FacebookPage');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAFacebookInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWebWidgetInbox returns true if channel type is Facebook', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::WebWidget' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::WebWidget');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is sms', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::Sms' } };
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::Sms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is twilio sms', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isALineChannel returns true if channel type is Line', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Line');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isALineChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATelegramChannel returns true if channel type is Telegram', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATelegramChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioChannel returns true if channel type is Twilio', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioSMSChannel returns true if channel type is Twilio and medium is SMS', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'sms',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
describe('isATwilioSMSChannel', () => {
|
||||
it('returns true if channel type is Twilio and medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
|
||||
it('returns false if channel type is Twilio but medium is not SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'whatsapp',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if channel type is not Twilio but medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if neither channel type is Twilio nor medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
|
||||
medium: 'not_sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if channel type is Twilio but medium is empty', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: undefined,
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('isAnEmailChannel returns true if channel type is email', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: { channel_type: 'Channel::Email' },
|
||||
};
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::Email');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
chat: {
|
||||
channel_type: 'Channel::TwitterProfile',
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForChat({
|
||||
channel_type: 'Channel::TwitterProfile',
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
};
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
|
||||
});
|
||||
|
||||
it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'sms',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
expect(wrapper.vm.twilioBadge).toBe('sms');
|
||||
});
|
||||
|
||||
it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
chat: {
|
||||
id: 1,
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
},
|
||||
};
|
||||
const Component = getComponentConfigForChat({
|
||||
id: 1,
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
|
||||
expect(wrapper.vm.twitterBadge).toBe('twitter-tweet');
|
||||
});
|
||||
|
||||
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: 'Channel::Telegram',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(false);
|
||||
expect(wrapper.vm.isATwitterInbox).toBe(false);
|
||||
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
|
||||
describe('Badges', () => {
|
||||
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(false);
|
||||
expect(wrapper.vm.isATwitterInbox).toBe(false);
|
||||
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
|
||||
});
|
||||
|
||||
it('inboxBadge returns correct badge for WhatsApp channel', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the twitterBadge when isATwitterInbox is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('twitter-dm');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the facebookBadge when isAFacebookInbox is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::FacebookPage');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('facebook');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the twilioBadge when isATwilioChannel is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('sms');
|
||||
});
|
||||
|
||||
it('inboxBadge returns "whatsapp" when isAWhatsAppChannel is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the channelType when no specific condition is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Email');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('Channel::Email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#inboxHasFeature', () => {
|
||||
it('detects the correct feature', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(true);
|
||||
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for feature not included', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Sms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(false);
|
||||
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhatsApp channel', () => {
|
||||
it('returns correct whatsAppAPIProvider', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.whatsAppAPIProvider).toBe('whatsapp_cloud');
|
||||
});
|
||||
|
||||
it('returns empty whatsAppAPIProvider if nothing is present', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: undefined,
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.whatsAppAPIProvider).toBe('');
|
||||
});
|
||||
|
||||
it('isAWhatsAppCloudChannel returns true if channel type is WhatsApp and provider is whatsapp_cloud', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('is360DialogWhatsAppChannel returns true if channel type is WhatsApp and provider is default', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'default',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'whatsapp',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -96,6 +98,10 @@ export default {
|
||||
this.clearConversations();
|
||||
this.clearConversationAttributes();
|
||||
this.replaceRoute('prechat-form');
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
});
|
||||
},
|
||||
async sendTranscript() {
|
||||
const { email } = this.currentUser;
|
||||
|
||||
@@ -40,6 +40,9 @@ import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
|
||||
export default {
|
||||
name: 'TeamAvailability',
|
||||
components: {
|
||||
@@ -82,6 +85,12 @@ export default {
|
||||
methods: {
|
||||
startConversation() {
|
||||
this.$emit('start-conversation');
|
||||
if (!this.hasConversation) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const CHATWOOT_ERROR = 'chatwoot:error';
|
||||
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
|
||||
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
|
||||
export const CHATWOOT_READY = 'chatwoot:ready';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { playNewMessageNotificationInWidget } from 'widget/helpers/WidgetAudioNo
|
||||
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
|
||||
import { CHATWOOT_ON_MESSAGE } from '../constants/sdkEvents';
|
||||
|
||||
const isMessageInActiveConversation = (getters, message) => {
|
||||
const { conversation_id: conversationId } = message;
|
||||
@@ -58,7 +59,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
.dispatch('conversation/addOrUpdateMessage', data)
|
||||
.then(() => window.bus.$emit(ON_AGENT_MESSAGE_RECEIVED));
|
||||
|
||||
IFrameHelper.sendMessage({ event: 'onMessage', data });
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_MESSAGE,
|
||||
data,
|
||||
});
|
||||
if (data.sender_type === 'User') {
|
||||
playNewMessageNotificationInWidget();
|
||||
}
|
||||
@@ -70,7 +75,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
}
|
||||
|
||||
if (shouldTriggerMessageUpdateEvent(data)) {
|
||||
IFrameHelper.sendMessage({ event: 'onMessage', data });
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_MESSAGE,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
|
||||
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class SlackUnfurlJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params, integration_hook)
|
||||
Integrations::Slack::SlackLinkUnfurlService.new(params: params, integration_hook: integration_hook).perform
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,7 @@ class DataImport < ApplicationRecord
|
||||
private
|
||||
|
||||
def process_data_import
|
||||
DataImportJob.perform_later(self)
|
||||
# we wait for the file to be uploaded to the cloud
|
||||
DataImportJob.set(wait: 1.minute).perform_later(self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,6 @@ json.id resource.id
|
||||
json.name resource.name
|
||||
json.source_link resource.source_link
|
||||
json.source_type resource.source_type
|
||||
json.inbox_id resource.inbox_id
|
||||
json.account_id resource.account_id
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
|
||||
@@ -28,8 +28,8 @@ By default, it renders:
|
||||
<% end %>
|
||||
</head>
|
||||
<body>
|
||||
<div class="antialiased">
|
||||
<main class="main-content min-h-screen flex flex-col" role="main">
|
||||
<div class="antialiased <%= @theme %>">
|
||||
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
@@ -40,6 +40,11 @@ By default, it renders:
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: '<%= @theme %>';
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.portalConfig = {
|
||||
portalSlug: '<%= @portal.slug %>',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="bg-white lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<section class="lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-xl text-slate-800 font-medium leading-relaxed hover:underline">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %>">
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-medium leading-relaxed hover:underline">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>">
|
||||
<%= category.name %>
|
||||
</a>
|
||||
</h3>
|
||||
@@ -11,20 +11,20 @@
|
||||
</div>
|
||||
<div class="py-4 w-full mt-2 flex-grow">
|
||||
<% if category.articles.published.size == 0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 rounded-xl mb-4">
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %>"
|
||||
class="text-slate-800 dark:text-slate-50 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<div class="flex justify-between content-center my-1 -mx-1 p-1 rounded-lg hover:bg-slate-25">
|
||||
<div class="flex justify-between content-center my-1 -mx-1 p-1 rounded-lg hover:bg-slate-25 dark:hover:bg-slate-800">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center">
|
||||
<svg
|
||||
class="w-4 h-4 fill-current text-slate-700"
|
||||
class="w-4 h-4 fill-current text-slate-700 dark:text-slate-200"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %>"
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
class="flex flex-row items-center text-base font-medium text-woot-500 hover:underline mt-4"
|
||||
style="color: <%= portal.color %>"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
<footer class="pt-16 pb-8 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<p class="text-slate-700 py-2 text-center">
|
||||
<p class="text-slate-700 dark:text-slate-100 py-2 text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %> <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a> 💙
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
|
||||
<header class="bg-white w-full shadow-sm sticky top-0 z-50">
|
||||
<nav class=" flex bg-white mx-auto max-w-6xl px-8" aria-label="Top">
|
||||
<header class="bg-white dark:bg-slate-900 w-full shadow-sm sticky top-0 z-50">
|
||||
<nav class=" flex mx-auto max-w-6xl px-8" aria-label="Top">
|
||||
<div class="w-full py-5 flex items-center">
|
||||
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/" class="h-10 text-lg flex items-center text-slate-900 font-semibold">
|
||||
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? ? '?theme='+@theme : '' %>" class="h-10 text-lg flex items-center text-slate-900 dark:text-white font-semibold">
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="h-10 w-auto mr-2" />
|
||||
<% end %>
|
||||
<%= @portal.name %>
|
||||
</a>
|
||||
<% if @portal.homepage_link %>
|
||||
<div class="ml-8 border-l-1 border-slate-50 hidden md:block">
|
||||
<div class="ml-8 border-l-1 border-slate-50 dark:border-slate-800 hidden md:block">
|
||||
<div class="flex-grow flex-shrink-0">
|
||||
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center text-sm font-medium text-slate-700 hover:text-slate-800 hover:underline"> <%= I18n.t('public_portal.header.go_to_homepage') %>
|
||||
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center text-sm font-medium text-slate-700 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-300 hover:underline"> <%= I18n.t('public_portal.header.go_to_homepage') %>
|
||||
<span class="ml-2">
|
||||
<svg class="w-4 h-4 fill-current text-slate-600" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z" /></svg>
|
||||
<svg class="w-4 h-4 fill-current text-slate-600 dark:text-slate-200" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z" /></svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -25,13 +25,13 @@
|
||||
<div class="inline-flex relative w-24">
|
||||
<select
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="appearance-none w-full bg-white px-3 py-2 pr-8 rounded leading-tight focus:outline-none focus:shadow-outline locale-switcher hover:bg-slate-75 cursor-pointer"
|
||||
class="appearance-none w-full bg-white dark:bg-slate-900 dark:text-slate-100 px-3 py-2 pr-8 rounded leading-tight focus:outline-none focus:shadow-outline locale-switcher hover:bg-slate-75 dark:hover:bg-slate-800 cursor-pointer"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= locale %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-700 dark:text-slate-200">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<section class="pt-8 pb-16 md:py-20 w-full bg-woot-50 shadow-inner" style="background: url(/assets/images/hc/grid.svg) color-mix(in srgb, <%= @portal.color %> 10%, white)">
|
||||
<section class="pt-8 pb-16 md:py-20 w-full bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div class="mx-auto max-w-6xl px-8 flex flex-col items-center sm:items-start">
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 font-semibold leading-normal">
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.header_text %>
|
||||
</h1>
|
||||
<p class="text-slate-600 text-center text-lg leading-normal pt-4 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<p class="text-slate-600 dark:text-slate-200 text-center text-lg leading-normal pt-4 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<div id="search-wrap"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="bg-white lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<section class="lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-xl text-slate-900 font-semibold leading-relaxed hover:underline">
|
||||
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline">
|
||||
<%= category %>
|
||||
</h3>
|
||||
<span class="text-slate-500">
|
||||
@@ -10,14 +10,14 @@
|
||||
<div class="py-4 w-full mt-2 flex-grow">
|
||||
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %>"
|
||||
class="text-slate-800 dark:text-slate-100 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<div class="flex justify-between content-center my-1 -mx-1 p-1 rounded-lg hover:bg-slate-25">
|
||||
<div class="flex justify-between content-center my-1 -mx-1 p-1 rounded-lg hover:bg-slate-25 dark:hover:bg-slate-800">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center">
|
||||
<svg
|
||||
class="w-4 h-4 fill-current text-slate-700"
|
||||
class="w-4 h-4 fill-current text-slate-700 dark:text-slate-200"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h1 class="text-3xl font-semibold md:tracking-normal leading-snug md:text-4xl text-slate-900">
|
||||
<h1 class="text-3xl font-semibold md:tracking-normal leading-snug md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= article.title %>
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
|
||||
@@ -9,8 +9,8 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 mb-2"><%= article.author.available_name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700">
|
||||
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.available_name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="bg-slate-50">
|
||||
<div class="bg-slate-50 dark:bg-slate-800">
|
||||
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %>"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
@@ -12,14 +12,14 @@
|
||||
<span>/</span>
|
||||
</div>
|
||||
<% @articles.each do |article| %>
|
||||
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900">
|
||||
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900 dark:text-white">
|
||||
<%= article.title %></h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 h-812 border rounded-full">
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 mb-2"><%= article.author.name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700">
|
||||
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
|
||||
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
<div class="max-w-4xl flex-grow w-full px-8 py-16 mx-auto space-y-12">
|
||||
<article class="space-y-8">
|
||||
<div class="text-slate-800 font-sans leading-8 text-lg max-w-3xl blog-content">
|
||||
<div class="text-slate-800 dark:text-slate-50 font-sans leading-8 text-lg max-w-3xl blog-content">
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -12,25 +12,25 @@
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div class="bg-woot-50 py-8 shadow-inner" style="background: url(/assets/images/hc/grid.svg) color-mix(in srgb, <%= @portal.color %> 10%, white)">
|
||||
<div class="bg-woot-50 dark:bg-woot-900 py-8 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div class="max-w-6xl px-8 pt-8 pb-16 mx-auto space-y-4 w-full">
|
||||
<div>
|
||||
<a
|
||||
class="text-slate-700 hover:underline leading-8 text-sm font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %>"
|
||||
class="text-slate-700 dark:text-slate-100 hover:underline leading-8 text-sm font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
<span class="text-xs text-slate-600 px-1">/</span>
|
||||
<span class="text-xs text-slate-600 dark:text-slate-200 px-1">/</span>
|
||||
<% if @article.category %>
|
||||
<a
|
||||
class="text-slate-700 hover:underline leading-8 text-sm font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %>"
|
||||
class="text-slate-700 dark:text-slate-100 hover:underline leading-8 text-sm font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<%= @article.category&.name %>
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="text-slate-700 leading-8 text-sm font-semibold" > Uncategorized </span>
|
||||
<span class="text-slate-700 dark:text-slate-100 leading-8 text-sm font-semibold" > Uncategorized </span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||
@@ -43,7 +43,7 @@
|
||||
<% end %>
|
||||
|
||||
<div class="flex max-w-6xl w-full px-8 mx-auto">
|
||||
<article id="cw-article-content" class="flex-grow flex-2 mx-auto text-slate-800 text-lg max-w-3xl prose break-words w-full <%= @is_plain_layout_enabled ? 'py-4' : 'py-12' %>">
|
||||
<article id="cw-article-content" class="flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-lg max-w-3xl prose dark:prose-invert break-words w-full <%= @is_plain_layout_enabled ? 'py-4' : 'py-12' %>">
|
||||
<%= @parsed_content %>
|
||||
</article>
|
||||
<div class="flex-1" id="cw-hc-toc"></div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
<section class="bg-white lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<section class="lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-xl text-slate-900 font-semibold leading-relaxed hover:underline"">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %>">
|
||||
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>">
|
||||
<%= category.name %>
|
||||
</a>
|
||||
</h3>
|
||||
@@ -12,21 +12,21 @@
|
||||
</div>
|
||||
<div class="py-4 w-full mt-2 flex-grow">
|
||||
<% if category.articles.published.size == 0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 rounded-xl mb-4">
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
|
||||
<div class="flex justify-between content-center h-8 my-1">
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %>"
|
||||
class="text-slate-800 dark:text-slate-50 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<%= article.title %>
|
||||
</a>
|
||||
<span class="flex items-center">
|
||||
<svg
|
||||
class="w-4 h-4 fill-current text-slate-700"
|
||||
class="w-4 h-4 fill-current text-slate-700 dark:text-slate-200"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
@@ -45,8 +45,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %>"
|
||||
class="flex flex-row items-center text-base font-medium text-woot-600 hover:text-slate-900 hover:underline mt-4"
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
class="flex flex-row items-center text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline mt-4"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.view_all_articles') %>
|
||||
<span class="ml-2">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<div class="px-8 max-w-6xl w-full mx-auto">
|
||||
<a
|
||||
class="text-slate-700 text-sm hover:underline leading-8 font-semibold"
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>"
|
||||
class="text-slate-700 dark:text-slate-100 text-sm hover:underline leading-8 font-semibold"
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="text-xs text-slate-600 px-1">/</span>
|
||||
<span class="text-xs text-slate-600 dark:text-slate-200 px-1">/</span>
|
||||
<div class="flex justify-start items-center w-full">
|
||||
<h1 class="text-3xl font-semibold leading-snug md:tracking-normal text-slate-900">
|
||||
<h1 class="text-3xl font-semibold leading-snug md:tracking-normal text-slate-900 dark:text-white">
|
||||
<%= category.name %>
|
||||
</h1>
|
||||
<div class="text-slate-500 px-8 mt-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
<section class="bg-slate-50 py-16 flex flex-col items-center justify-center">
|
||||
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-4xl text-slate-900 font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
|
||||
<p class="text-slate-700 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
|
||||
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,23 +6,23 @@
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div class="bg-woot-50 pt-16 pb-20" style="background: url(/assets/images/hc/grid.svg) color-mix(in srgb, <%= @portal.color %> 10%, white)">
|
||||
<div class="bg-woot-50 dark:bg-woot-900 pt-16 pb-20" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
||||
<% end %>
|
||||
<section class="bg-white max-w-6xl w-full mx-auto py-6 px-8 flex flex-col items-center justify-center flex-grow">
|
||||
<section class="max-w-6xl w-full mx-auto py-6 px-8 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="py-4 w-full mt-2 flex-grow">
|
||||
<% if @category.articles.published.size == 0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 rounded-xl py-6">
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% @category.articles.published.order(:position).each do |article| %>
|
||||
<a
|
||||
class="text-slate-800 flex justify-between content-center mb-4 py-2"
|
||||
href="/hc/<%= @portal.slug %>/articles/<%= article.slug %>"
|
||||
class="text-slate-800 dark:text-slate-50 flex justify-between content-center mb-4 py-2"
|
||||
href="/hc/<%= @portal.slug %>/articles/<%= article.slug %><%= @theme.present? ? '?theme='+@theme : '' %>"
|
||||
>
|
||||
<div>
|
||||
<p class="mb-2 hover:underline font-semibold"><%= article.title %></p>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<span>
|
||||
<svg
|
||||
class="w-4 h-4 fill-current text-slate-700"
|
||||
class="w-4 h-4 fill-current text-slate-700 dark:text-slate-200"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
|
||||
@@ -59,4 +59,9 @@ window.addEventListener('chatwoot:error', function(e) {
|
||||
window.addEventListener('chatwoot:on-message', function(e) {
|
||||
console.log('chatwoot:on-message', e.detail)
|
||||
})
|
||||
|
||||
|
||||
window.addEventListener('chatwoot:on-start-conversation', function(e) {
|
||||
console.log('chatwoot:on-start-conversation')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module Slack
|
||||
module Web
|
||||
module Api
|
||||
module Endpoints
|
||||
module Chat
|
||||
# TODO: Remove this monkey patch when PR for this issue https://github.com/slack-ruby/slack-ruby-client/issues/388 is merged
|
||||
def chat_unfurl(options = {})
|
||||
if (options[:channel].nil? || options[:ts].nil?) && (options[:unfurl_id].nil? || options[:source].nil?)
|
||||
raise ArgumentError, 'Either a combination of :channel and :ts or :unfurl_id and :source is required'
|
||||
end
|
||||
|
||||
raise ArgumentError, 'Required arguments :unfurls missing' if options[:unfurls].nil?
|
||||
|
||||
options = options.merge(channel: conversations_id(options)['channel']['id']) if options[:channel]
|
||||
post('chat.unfurl', options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,3 +33,4 @@ ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.ignore_extentions <<
|
||||
ActiveRecord::SchemaDumper.ignore_tables << 'responses'
|
||||
ActiveRecord::SchemaDumper.ignore_tables << 'response_sources'
|
||||
ActiveRecord::SchemaDumper.ignore_tables << 'response_documents'
|
||||
ActiveRecord::SchemaDumper.ignore_tables << 'inbox_response_sources'
|
||||
|
||||
@@ -46,7 +46,7 @@ class Rack::Attack
|
||||
#
|
||||
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
|
||||
|
||||
throttle('req/ip', limit: ENV.fetch('RACK_ATTACK_LIMIT', 3000), period: 1.minute, &:ip)
|
||||
throttle('req/ip', limit: ENV.fetch('RACK_ATTACK_LIMIT', '3000').to_i, period: 1.minute, &:ip)
|
||||
|
||||
###-----------------------------------------------###
|
||||
###-----Authentication Related Throttling---------###
|
||||
|
||||
@@ -218,3 +218,12 @@ en:
|
||||
made_with: Made with
|
||||
header:
|
||||
go_to_homepage: Go to the main site
|
||||
slack_unfurl:
|
||||
fields:
|
||||
name: Name
|
||||
email: Email
|
||||
phone_number: Phone
|
||||
company_name: Company
|
||||
inbox_name: Inbox
|
||||
inbox_type: Inbox Type
|
||||
button: Open conversation
|
||||
|
||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ResponseSourcesController < Api::V1::Accounts::BaseCont
|
||||
end
|
||||
|
||||
def response_source_params
|
||||
params.require(:response_source).permit(:name, :source_link, :inbox_id,
|
||||
params.require(:response_source).permit(:name, :source_link,
|
||||
response_documents_attributes: [:document_link])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,8 +10,13 @@ class ResponseDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number.with_options(searchable: true),
|
||||
account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable_field: [:name, :id], order: 'id DESC'),
|
||||
response_source: Field::BelongsToSearch.with_options(class_name: 'ResponseSource', searchable_field: [:name, :id, :source_link],
|
||||
order: 'id DESC'),
|
||||
answer: Field::Text.with_options(searchable: true),
|
||||
question: Field::String.with_options(searchable: true),
|
||||
status: Field::Select.with_options(searchable: false, collection: lambda { |field|
|
||||
field.resource.class.send(field.attribute.to_s.pluralize).keys
|
||||
}),
|
||||
response_document: Field::BelongsToSearch.with_options(class_name: 'ResponseDocument', searchable_field: [:document_link, :content, :id],
|
||||
order: 'id DESC'),
|
||||
created_at: Field::DateTime,
|
||||
@@ -27,7 +32,9 @@ class ResponseDashboard < Administrate::BaseDashboard
|
||||
id
|
||||
question
|
||||
answer
|
||||
status
|
||||
response_document
|
||||
response_source
|
||||
account
|
||||
].freeze
|
||||
|
||||
@@ -35,9 +42,11 @@ class ResponseDashboard < Administrate::BaseDashboard
|
||||
# an array of attributes that will be displayed on the model's show page.
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
status
|
||||
question
|
||||
answer
|
||||
response_document
|
||||
response_source
|
||||
account
|
||||
created_at
|
||||
updated_at
|
||||
@@ -47,10 +56,11 @@ class ResponseDashboard < Administrate::BaseDashboard
|
||||
# an array of attributes that will be displayed
|
||||
# on the model's form (`new` and `edit`) pages.
|
||||
FORM_ATTRIBUTES = %i[
|
||||
response_source
|
||||
response_document
|
||||
question
|
||||
answer
|
||||
response_document
|
||||
account
|
||||
status
|
||||
].freeze
|
||||
|
||||
# COLLECTION_FILTERS
|
||||
@@ -63,7 +73,12 @@ class ResponseDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
account: ->(resources, attr) { resources.where(account_id: attr) },
|
||||
response_source: ->(resources, attr) { resources.where(response_source_id: attr) },
|
||||
response_document: ->(resources, attr) { resources.where(response_document_id: attr) },
|
||||
status: ->(resources, attr) { resources.where(status: attr) }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how responses are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -38,11 +38,11 @@ class ResponseDocumentDashboard < Administrate::BaseDashboard
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
account
|
||||
content
|
||||
document_id
|
||||
document_link
|
||||
document_type
|
||||
response_source
|
||||
document_link
|
||||
document_id
|
||||
document_type
|
||||
content
|
||||
created_at
|
||||
updated_at
|
||||
responses
|
||||
@@ -53,11 +53,11 @@ class ResponseDocumentDashboard < Administrate::BaseDashboard
|
||||
# on the model's form (`new` and `edit`) pages.
|
||||
FORM_ATTRIBUTES = %i[
|
||||
account
|
||||
content
|
||||
document_id
|
||||
document_link
|
||||
document_type
|
||||
response_source
|
||||
document_link
|
||||
document_id
|
||||
document_type
|
||||
content
|
||||
].freeze
|
||||
|
||||
# COLLECTION_FILTERS
|
||||
@@ -70,7 +70,10 @@ class ResponseDocumentDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
account: ->(resources, attr) { resources.where(account_id: attr) },
|
||||
response_source: ->(resources, attr) { resources.where(response_source_id: attr) }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how response documents are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -8,7 +8,7 @@ class ResponseSourceDashboard < Administrate::BaseDashboard
|
||||
# which determines how the attribute is displayed
|
||||
# on pages throughout the dashboard.
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
id: Field::Number.with_options(searchable: true),
|
||||
account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable_field: [:name, :id], order: 'id DESC'),
|
||||
name: Field::String.with_options(searchable: true),
|
||||
response_documents: Field::HasMany,
|
||||
@@ -73,7 +73,9 @@ class ResponseSourceDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
account: ->(resources, attr) { resources.where(account_id: attr) }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how response sources are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -63,14 +63,20 @@ class ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def create_responses(response, response_document)
|
||||
response_body = JSON.parse(response.body)
|
||||
faqs = JSON.parse(response_body['choices'][0]['message']['content'].strip)
|
||||
content = response_body.dig('choices', 0, 'message', 'content')
|
||||
|
||||
return if content.nil?
|
||||
|
||||
faqs = JSON.parse(content.strip)
|
||||
|
||||
faqs.each do |faq|
|
||||
response_document.responses.create!(
|
||||
question: faq['question'],
|
||||
answer: faq['answer'],
|
||||
account_id: response_document.account_id
|
||||
response_source: response_document.response_source
|
||||
)
|
||||
end
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response document : #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: audits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# action :string
|
||||
# associated_type :string
|
||||
# auditable_type :string
|
||||
# audited_changes :jsonb
|
||||
# comment :string
|
||||
# remote_address :string
|
||||
# request_uuid :string
|
||||
# user_type :string
|
||||
# username :string
|
||||
# version :integer default(0)
|
||||
# created_at :datetime
|
||||
# associated_id :bigint
|
||||
# auditable_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# associated_index (associated_type,associated_id)
|
||||
# auditable_index (auditable_type,auditable_id,version)
|
||||
# index_audits_on_created_at (created_at)
|
||||
# index_audits_on_request_uuid (request_uuid)
|
||||
# user_index (user_id,user_type)
|
||||
#
|
||||
class Enterprise::AuditLog < Audited::Audit
|
||||
after_save :log_additional_information
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ module Enterprise::Concerns::Inbox
|
||||
|
||||
included do
|
||||
def self.add_response_related_associations
|
||||
has_many :response_sources, dependent: :destroy_async
|
||||
has_many :inbox_response_sources, dependent: :destroy_async
|
||||
has_many :response_sources, through: :inbox_response_sources
|
||||
has_many :response_documents, through: :response_sources
|
||||
has_many :responses, through: :response_documents
|
||||
has_many :responses, through: :response_sources
|
||||
end
|
||||
|
||||
add_response_related_associations if Features::ResponseBotService.new.vector_extension_enabled?
|
||||
|
||||
@@ -7,7 +7,7 @@ module Enterprise::Inbox
|
||||
|
||||
def get_responses(query)
|
||||
embedding = Openai::EmbeddingsService.new.get_embedding(query)
|
||||
responses.nearest_neighbors(:embedding, embedding, distance: 'cosine').first(5)
|
||||
responses.active.nearest_neighbors(:embedding, embedding, distance: 'cosine').first(5)
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_response_sources
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# inbox_id :bigint not null
|
||||
# response_source_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_response_sources_on_inbox_id (inbox_id)
|
||||
# index_inbox_response_sources_on_inbox_id_and_response_source_id (inbox_id,response_source_id) UNIQUE
|
||||
# index_inbox_response_sources_on_response_source_id (response_source_id)
|
||||
# index_inbox_response_sources_on_response_source_id_and_inbox_id (response_source_id,inbox_id) UNIQUE
|
||||
#
|
||||
class InboxResponseSource < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
belongs_to :response_source
|
||||
end
|
||||
@@ -6,10 +6,12 @@
|
||||
# answer :text not null
|
||||
# embedding :vector(1536)
|
||||
# question :string not null
|
||||
# status :integer default(0)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# response_document_id :bigint
|
||||
# response_source_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -17,11 +19,15 @@
|
||||
# index_responses_on_response_document_id (response_document_id)
|
||||
#
|
||||
class Response < ApplicationRecord
|
||||
belongs_to :response_document
|
||||
belongs_to :response_document, optional: true
|
||||
belongs_to :account
|
||||
belongs_to :response_source
|
||||
has_neighbors :embedding, normalize: true
|
||||
|
||||
before_save :update_response_embedding
|
||||
before_validation :ensure_account
|
||||
|
||||
enum status: { pending: 0, active: 1 }
|
||||
|
||||
def self.search(query)
|
||||
embedding = Openai::EmbeddingsService.new.get_embedding(query)
|
||||
@@ -30,6 +36,10 @@ class Response < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = response_source.account
|
||||
end
|
||||
|
||||
def update_response_embedding
|
||||
self.embedding = Openai::EmbeddingsService.new.get_embedding("#{question}: #{answer}")
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# index_response_documents_on_response_source_id (response_source_id)
|
||||
#
|
||||
class ResponseDocument < ApplicationRecord
|
||||
has_many :responses, dependent: :destroy
|
||||
has_many :responses, dependent: :destroy_async
|
||||
belongs_to :account
|
||||
belongs_to :response_source
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# source_model_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
@@ -19,10 +18,11 @@
|
||||
#
|
||||
class ResponseSource < ApplicationRecord
|
||||
enum source_type: { external: 0, kbase: 1, inbox: 2 }
|
||||
has_many :inbox_response_sources, dependent: :destroy_async
|
||||
has_many :inboxes, through: :inbox_response_sources
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
has_many :response_documents, dependent: :destroy
|
||||
has_many :responses, through: :response_documents
|
||||
has_many :response_documents, dependent: :destroy_async
|
||||
has_many :responses, dependent: :destroy_async
|
||||
|
||||
accepts_nested_attributes_for :response_documents
|
||||
end
|
||||
|
||||
@@ -23,19 +23,31 @@ class Features::ResponseBotService
|
||||
def create_tables
|
||||
return unless vector_extension_enabled?
|
||||
|
||||
%i[response_sources response_documents responses].each do |table|
|
||||
%i[response_sources response_documents responses inbox_response_sources].each do |table|
|
||||
send("create_#{table}_table")
|
||||
end
|
||||
end
|
||||
|
||||
def drop_tables
|
||||
%i[responses response_documents response_sources].each do |table|
|
||||
%i[responses response_documents response_sources inbox_response_sources].each do |table|
|
||||
MIGRATION_VERSION.drop_table table if MIGRATION_VERSION.table_exists?(table)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_inbox_response_sources_table
|
||||
return if MIGRATION_VERSION.table_exists?(:inbox_response_sources)
|
||||
|
||||
MIGRATION_VERSION.create_table :inbox_response_sources do |t|
|
||||
t.references :inbox, null: false
|
||||
t.references :response_source, null: false
|
||||
t.index [:inbox_id, :response_source_id], name: 'index_inbox_response_sources_on_inbox_id_and_response_source_id', unique: true
|
||||
t.index [:response_source_id, :inbox_id], name: 'index_inbox_response_sources_on_response_source_id_and_inbox_id', unique: true
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def create_response_sources_table
|
||||
return if MIGRATION_VERSION.table_exists?(:response_sources)
|
||||
|
||||
@@ -45,7 +57,6 @@ class Features::ResponseBotService
|
||||
t.string :source_link
|
||||
t.references :source_model, polymorphic: true
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :inbox_id, null: false
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
@@ -69,9 +80,11 @@ class Features::ResponseBotService
|
||||
return if MIGRATION_VERSION.table_exists?(:responses)
|
||||
|
||||
MIGRATION_VERSION.create_table :responses do |t|
|
||||
t.bigint :response_source_id, null: false
|
||||
t.bigint :response_document_id
|
||||
t.string :question, null: false
|
||||
t.text :answer, null: false
|
||||
t.integer :status, default: 0
|
||||
t.bigint :account_id, null: false
|
||||
t.vector :embedding, limit: 1536
|
||||
t.timestamps
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
class Integrations::Slack::IncomingMessageBuilder
|
||||
include Integrations::Slack::SlackMessageHelper
|
||||
|
||||
attr_reader :params
|
||||
|
||||
SUPPORTED_EVENT_TYPES = %w[event_callback url_verification].freeze
|
||||
SUPPORTED_EVENTS = %w[message].freeze
|
||||
SUPPORTED_EVENTS = %w[message link_shared].freeze
|
||||
SUPPORTED_MESSAGE_TYPES = %w[rich_text].freeze
|
||||
|
||||
def initialize(params)
|
||||
@@ -17,6 +18,8 @@ class Integrations::Slack::IncomingMessageBuilder
|
||||
verify_hook
|
||||
elsif create_message?
|
||||
create_message
|
||||
elsif link_shared?
|
||||
SlackUnfurlJob.perform_later(params, integration_hook)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +71,10 @@ class Integrations::Slack::IncomingMessageBuilder
|
||||
thread_timestamp_available? && supported_message? && integration_hook
|
||||
end
|
||||
|
||||
def link_shared?
|
||||
params[:event][:type] == 'link_shared' && integration_hook
|
||||
end
|
||||
|
||||
def message
|
||||
params[:event][:blocks]&.first
|
||||
end
|
||||
@@ -82,19 +89,6 @@ class Integrations::Slack::IncomingMessageBuilder
|
||||
@integration_hook ||= Integrations::Hook.find_by(reference_id: params[:event][:channel])
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
|
||||
end
|
||||
|
||||
def sender
|
||||
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
|
||||
conversation.account.users.find_by(email: user_email)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
class Integrations::Slack::LinkUnfurlFormatter
|
||||
pattr_initialize [:url!, :user_info!, :inbox_name!, :inbox_type!]
|
||||
|
||||
def perform
|
||||
return {} if url.blank?
|
||||
|
||||
{
|
||||
url => {
|
||||
'blocks' => preivew_blocks(user_info) +
|
||||
open_conversation_button(url)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preivew_blocks(user_info)
|
||||
[
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
preview_field(I18n.t('slack_unfurl.fields.name'), user_info[:user_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.email'), user_info[:email]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.phone_number'), user_info[:phone_number]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.company_name'), user_info[:company_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_name'), inbox_name),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_type'), inbox_type)
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def preview_field(label, value)
|
||||
{
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*#{label}:*\n#{value}"
|
||||
}
|
||||
end
|
||||
|
||||
def open_conversation_button(url)
|
||||
[
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => {
|
||||
'type' => 'plain_text',
|
||||
'text' => I18n.t('slack_unfurl.button'),
|
||||
'emoji' => true
|
||||
},
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,12 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
perform_reply
|
||||
end
|
||||
|
||||
def link_unfurl(event)
|
||||
slack_client.chat_unfurl(
|
||||
event
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_channel_for_slack?
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class Integrations::Slack::SlackLinkUnfurlService
|
||||
pattr_initialize [:params!, :integration_hook!]
|
||||
|
||||
def perform
|
||||
event_links = params.dig(:event, :links)
|
||||
return unless event_links
|
||||
|
||||
event_links.each do |link_info|
|
||||
url = link_info[:url]
|
||||
# Unfurl only if the account id is same as the integration hook account id
|
||||
unfurl_link(url) if url && valid_account?(url)
|
||||
end
|
||||
end
|
||||
|
||||
def unfurl_link(url)
|
||||
conversation = conversation_from_url(url)
|
||||
return unless conversation
|
||||
|
||||
send_unfurls(url, conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact_attributes(conversation)
|
||||
contact = conversation.contact
|
||||
{
|
||||
user_name: contact.name.presence || '---',
|
||||
email: contact.email.presence || '---',
|
||||
phone_number: contact.phone_number.presence || '---',
|
||||
company_name: contact.additional_attributes&.dig('company_name').presence || '---'
|
||||
}
|
||||
end
|
||||
|
||||
def generate_unfurls(url, user_info, inbox)
|
||||
Integrations::Slack::LinkUnfurlFormatter.new(
|
||||
url: url,
|
||||
user_info: user_info,
|
||||
inbox_name: inbox.name,
|
||||
inbox_type: inbox.channel.name
|
||||
).perform
|
||||
end
|
||||
|
||||
def send_unfurls(url, conversation)
|
||||
user_info = contact_attributes(conversation)
|
||||
unfurls = generate_unfurls(url, user_info, conversation.inbox)
|
||||
unfurl_params = {
|
||||
unfurl_id: params.dig(:event, :unfurl_id),
|
||||
source: params.dig(:event, :source),
|
||||
unfurls: JSON.generate(unfurls)
|
||||
}
|
||||
|
||||
slack_service = Integrations::Slack::SendOnSlackService.new(
|
||||
message: nil,
|
||||
hook: integration_hook
|
||||
)
|
||||
slack_service.link_unfurl(unfurl_params)
|
||||
end
|
||||
|
||||
def conversation_from_url(url)
|
||||
conversation_id = extract_conversation_id(url)
|
||||
find_conversation_by_id(conversation_id) if conversation_id
|
||||
end
|
||||
|
||||
def find_conversation_by_id(conversation_id)
|
||||
Conversation.find_by(display_id: conversation_id, account_id: integration_hook.account_id)
|
||||
end
|
||||
|
||||
def valid_account?(url)
|
||||
account_id = extract_account_id(url)
|
||||
account_id == integration_hook.account_id.to_s
|
||||
end
|
||||
|
||||
def extract_account_id(url)
|
||||
account_id_regex = %r{/accounts/(\d+)}
|
||||
match_data = url.match(account_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
|
||||
def extract_conversation_id(url)
|
||||
conversation_id_regex = %r{/conversations/(\d+)}
|
||||
match_data = url.match(conversation_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
end
|
||||
@@ -62,4 +62,17 @@ module Integrations::Slack::SlackMessageHelper
|
||||
:file
|
||||
end
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
|
||||
end
|
||||
|
||||
def sender
|
||||
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
|
||||
conversation.account.users.find_by(email: user_email)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,10 @@ if Rails.env.development?
|
||||
'show_complete_foreign_keys' => 'false',
|
||||
'show_indexes' => 'true',
|
||||
'simple_indexes' => 'false',
|
||||
'model_dir' => 'app/models',
|
||||
'model_dir' => [
|
||||
'app/models',
|
||||
'enterprise/app/models',
|
||||
],
|
||||
'root_dir' => '',
|
||||
'include_version' => 'false',
|
||||
'require' => '',
|
||||
|
||||
+2
-1
@@ -67,7 +67,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"ninja-keys": "^1.2.2",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-loader": "^4.2.0",
|
||||
"semver": "7.5.2",
|
||||
"tailwindcss": "^3.3.2",
|
||||
@@ -136,6 +136,7 @@
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"husky": "^7.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
"jest-transform-stub": "^2.0.0",
|
||||
"lint-staged": "14.0.1",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="#b0c7d4"
|
||||
stroke-opacity="0.04">
|
||||
<path d="M0 .5H31.5V32" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -44,6 +44,17 @@ RSpec.describe 'Public Inbox Contact Conversations API', type: :request do
|
||||
data = response.parsed_body
|
||||
expect(data['id']).not_to be_nil
|
||||
end
|
||||
|
||||
it 'creates a conversation with custom attributes but prevents other attributes' do
|
||||
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations",
|
||||
params: { custom_attributes: { 'test' => 'test' }, additional_attributes: { 'test' => 'test' } }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
conversation = api_channel.inbox.conversations.find_by(display_id: data['id'])
|
||||
expect(conversation.custom_attributes).to eq('test' => 'test')
|
||||
expect(conversation.additional_attributes).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /public/api/v1/inboxes/{identifier}/contact/{source_id}/conversations/{conversation_id}/toggle_typing' do
|
||||
|
||||
@@ -3,7 +3,6 @@ require 'rails_helper'
|
||||
RSpec.describe 'Response Sources API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
skip('Skipping since vector is not enabled in this environment') unless Features::ResponseBotService.new.vector_extension_enabled?
|
||||
@@ -44,7 +43,6 @@ RSpec.describe 'Response Sources API', type: :request do
|
||||
response_source: {
|
||||
name: 'Test',
|
||||
source_link: 'http://test.test',
|
||||
inbox_id: inbox.id,
|
||||
response_documents_attributes: [
|
||||
{ document_link: 'http://test1.test' },
|
||||
{ document_link: 'http://test2.test' }
|
||||
@@ -75,7 +73,7 @@ RSpec.describe 'Response Sources API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/response_sources/{response_source.id}/add_document' do
|
||||
let!(:response_source) { create(:response_source, account: account, inbox: inbox) }
|
||||
let!(:response_source) { create(:response_source, account: account) }
|
||||
let(:valid_params) do
|
||||
{ document_link: 'http://test.test' }
|
||||
end
|
||||
@@ -103,7 +101,7 @@ RSpec.describe 'Response Sources API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/response_sources/{response_source.id}/remove_document' do
|
||||
let!(:response_source) { create(:response_source, account: account, inbox: inbox) }
|
||||
let!(:response_source) { create(:response_source, account: account) }
|
||||
let!(:response_document) { response_source.response_documents.create!(document_link: 'http://test.test') }
|
||||
let(:valid_params) do
|
||||
{ document_id: response_document.id }
|
||||
|
||||
@@ -71,7 +71,9 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
it 'returns all response_sources belonging to the inbox to administrators' do
|
||||
response_source = create(:response_source, account: account, inbox: inbox)
|
||||
response_source = create(:response_source, account: account)
|
||||
inbox.response_sources << response_source
|
||||
inbox.save!
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/response_sources",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe PortalHelper do
|
||||
describe '#generate_portal_bg_color' do
|
||||
context 'when theme is dark' do
|
||||
it 'returns the correct color mix with black' do
|
||||
expect(helper.generate_portal_bg_color('#ff0000', 'dark')).to eq(
|
||||
'color-mix(in srgb, #ff0000 10%, black)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not dark' do
|
||||
it 'returns the correct color mix with white' do
|
||||
expect(helper.generate_portal_bg_color('#ff0000', 'light')).to eq(
|
||||
'color-mix(in srgb, #ff0000 10%, white)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provided with various colors' do
|
||||
it 'adjusts the color mix appropriately' do
|
||||
expect(helper.generate_portal_bg_color('#00ff00', 'dark')).to eq(
|
||||
'color-mix(in srgb, #00ff00 10%, black)'
|
||||
)
|
||||
expect(helper.generate_portal_bg_color('#0000ff', 'light')).to eq(
|
||||
'color-mix(in srgb, #0000ff 10%, white)'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_portal_bg' do
|
||||
context 'when theme is dark' do
|
||||
it 'returns the correct background with dark grid image and color mix with black' do
|
||||
expected_bg = 'background: url(/assets/images/hc/grid_dark.svg) color-mix(in srgb, #ff0000 10%, black)'
|
||||
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not dark' do
|
||||
it 'returns the correct background with light grid image and color mix with white' do
|
||||
expected_bg = 'background: url(/assets/images/hc/grid.svg) color-mix(in srgb, #ff0000 10%, white)'
|
||||
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provided with various colors' do
|
||||
it 'adjusts the background appropriately for dark theme' do
|
||||
expected_bg = 'background: url(/assets/images/hc/grid_dark.svg) color-mix(in srgb, #00ff00 10%, black)'
|
||||
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
|
||||
end
|
||||
|
||||
it 'adjusts the background appropriately for light theme' do
|
||||
expected_bg = 'background: url(/assets/images/hc/grid.svg) color-mix(in srgb, #0000ff 10%, white)'
|
||||
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,20 @@ RSpec.describe DataImportJob do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'retrying the job' do
|
||||
context 'when ActiveStorage::FileNotFoundError is raised' do
|
||||
before do
|
||||
allow(data_import.import_file).to receive(:download).and_raise(ActiveStorage::FileNotFoundError)
|
||||
end
|
||||
|
||||
it 'retries the job' do
|
||||
expect do
|
||||
described_class.perform_now(data_import)
|
||||
end.to have_enqueued_job(described_class).at_least(1).times
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'importing data' do
|
||||
context 'when the data is valid' do
|
||||
it 'imports data into the account' do
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlackUnfurlJob do
|
||||
subject(:job) { described_class.perform_later(params: link_shared, integration_hook: hook) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, inbox: inbox) }
|
||||
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
it 'calls the SlackLinkUnfurlService' do
|
||||
slack_link_unfurl_service = instance_double(Integrations::Slack::SlackLinkUnfurlService)
|
||||
|
||||
expect(Integrations::Slack::SlackLinkUnfurlService).to receive(:new)
|
||||
.with(params: link_shared, integration_hook: hook)
|
||||
.and_return(slack_link_unfurl_service)
|
||||
expect(slack_link_unfurl_service).to receive(:perform)
|
||||
described_class.perform_now(link_shared, hook)
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ require 'rails_helper'
|
||||
describe Integrations::Slack::IncomingMessageBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:message_params) { slack_message_stub }
|
||||
let(:builder) { described_class.new(hook: hook) }
|
||||
let(:private_message_params) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
@@ -30,6 +31,8 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:slack_client) { double }
|
||||
let(:link_unfurl_service) { double }
|
||||
let(:message_with_attachments) { slack_attachment_stub }
|
||||
let(:message_without_thread_ts) { slack_message_stub_without_thread_ts }
|
||||
let(:verification_params) { slack_url_verification_stub }
|
||||
@@ -145,5 +148,24 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link shared' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge({ links: [{ url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com' }] }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
|
||||
it 'unfurls link' do
|
||||
builder = described_class.new(link_shared)
|
||||
expect(SlackUnfurlJob).to receive(:perform_later).with(link_shared, hook)
|
||||
builder.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::LinkUnfurlFormatter do
|
||||
let(:contact) { create(:contact) }
|
||||
let(:inbox) { create(:inbox) }
|
||||
let(:url) { 'https://example.com/app/accounts/1/conversations/100' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when unfurling a URL' do
|
||||
let(:user_info) do
|
||||
{
|
||||
user_name: contact.name,
|
||||
email: contact.email,
|
||||
phone_number: '---',
|
||||
company_name: '---'
|
||||
}
|
||||
end
|
||||
|
||||
let(:expected_payload) do
|
||||
{
|
||||
url => {
|
||||
'blocks' => [
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Name:*\n#{contact.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Email:*\n#{contact.email}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Phone:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Company:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox:*\n#{inbox.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox Type:*\n#{inbox.channel_type}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => { 'type' => 'plain_text', 'text' => 'Open conversation', 'emoji' => true },
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns expected unfurl blocks when the URL is not blank' do
|
||||
formatter = described_class.new(url: url, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq(expected_payload)
|
||||
end
|
||||
|
||||
it 'returns an empty hash when the URL is blank' do
|
||||
formatter = described_class.new(url: nil, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,12 +18,14 @@ describe Integrations::Slack::SendOnSlackService do
|
||||
let(:slack_message_content) { double }
|
||||
let(:slack_client) { double }
|
||||
let(:builder) { described_class.new(message: message, hook: hook) }
|
||||
let(:link_builder) { described_class.new(message: nil, hook: hook) }
|
||||
let(:conversation_link) do
|
||||
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}|Click here> to view the conversation."
|
||||
end
|
||||
|
||||
before do
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(link_builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(slack_message).to receive(:[]).with('ts').and_return('12345.6789')
|
||||
allow(slack_message).to receive(:[]).with('message').and_return(slack_message_content)
|
||||
allow(slack_message_content).to receive(:[]).with('ts').and_return('6789.12345')
|
||||
@@ -135,6 +137,18 @@ describe Integrations::Slack::SendOnSlackService do
|
||||
expect(conversation.identifier).to eq '1691652432.896169'
|
||||
end
|
||||
|
||||
it 'sent lnk unfurl to slack' do
|
||||
unflur_payload = { :channel => 'channel',
|
||||
:ts => 'timestamp',
|
||||
:unfurls =>
|
||||
{ :'https://qa.chatwoot.com/app/accounts/1/conversations/1' =>
|
||||
{ :blocks => [{ :type => 'section',
|
||||
:text => { :type => 'plain_text', :text => 'This is a plain text section block.', :emoji => true } }] } } }
|
||||
allow(slack_client).to receive(:chat_unfurl).with(unflur_payload)
|
||||
link_builder.link_unfurl(unflur_payload)
|
||||
expect(slack_client).to have_received(:chat_unfurl).with(unflur_payload)
|
||||
end
|
||||
|
||||
it 'sent attachment on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::SlackLinkUnfurlService do
|
||||
let!(:contact) { create(:contact, name: 'Contact 1', email: nil, phone_number: nil) }
|
||||
let(:channel_email) { create(:channel_email) }
|
||||
let!(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: nil) }
|
||||
let(:account) { conversation.account }
|
||||
let!(:hook) { create(:integrations_hook, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the event does not contain any link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: []),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the account id which does not match the integration hook account id' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1212/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the conversation id which does not belong to the account' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1213',
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing single link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'sends a POST unfurl request to Slack' do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([{ url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1', domain: 'qa.chatwoot.com' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing multiple links' do
|
||||
let(:link_shared_1) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
},
|
||||
{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared_1, integration_hook: hook) }
|
||||
|
||||
it('sends multiple POST unfurl request to Slack') do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
expect { link_builder.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,4 +10,14 @@ RSpec.describe DataImport do
|
||||
expect(build(:data_import, data_type: 'Xyc').valid?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:data_import) { build(:data_import) }
|
||||
|
||||
it 'schedules a job after creation' do
|
||||
expect do
|
||||
data_import.save
|
||||
end.to have_enqueued_job(DataImportJob).with(data_import).on_queue('low')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -107,4 +107,14 @@ module SlackStubs
|
||||
channel: 'G01354F6A6Q'
|
||||
}
|
||||
end
|
||||
|
||||
def link_shared_event
|
||||
{
|
||||
type: 'link_shared',
|
||||
user: 'ULYPAKE5S',
|
||||
source: 'conversations_history',
|
||||
unfurl_id: 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
channel: 'G01354F6A6Q'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -138,6 +138,9 @@ public_message_create_payload:
|
||||
public_message_update_payload:
|
||||
$ref: ./request/public/message/update_payload.yml
|
||||
|
||||
public_conversation_create_payload:
|
||||
$ref: ./request/public/conversation/create_payload.yml
|
||||
|
||||
## ---------- RESPONSE ------------- ##
|
||||
## -------------------------------- ##
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
type: object
|
||||
properties:
|
||||
custom_attributes:
|
||||
type: object
|
||||
description: Custom attributes of the conversation
|
||||
@@ -4,6 +4,12 @@ operationId: create-a-conversation
|
||||
summary: Create a conversation
|
||||
description: Create a conversation
|
||||
security: []
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/public_conversation_create_payload'
|
||||
responses:
|
||||
200:
|
||||
description: Success
|
||||
|
||||
@@ -847,6 +847,16 @@
|
||||
"description": "Create a conversation",
|
||||
"security": [
|
||||
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "data",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/public_conversation_create_payload"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -6617,6 +6627,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation_create_payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"custom_attributes": {
|
||||
"type": "object",
|
||||
"description": "Custom attributes of the conversation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extended_contact": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user