Compare commits

...
Author SHA1 Message Date
Shivam Mishra 7adf7ab115 refactor: better variable names 2023-10-04 18:22:35 +05:30
Shivam Mishra ba1eaa169a chore: revert flag condition to false 2023-10-04 18:16:34 +05:30
Shivam Mishra a862ddfafe test: new inReplyTo states 2023-10-04 18:14:15 +05:30
Shivam Mishra 249fcdf471 fix: event handler call 2023-10-04 18:14:15 +05:30
Shivam Mishra e4620291a7 feat: setup inReplyTo store 2023-10-04 18:14:15 +05:30
Shivam MishraandGitHub 3aa79dffef fix: event name 2023-10-04 18:13:52 +05:30
Shivam MishraandGitHub 758b7bf061 Merge branch 'develop' into feature/cw-2594 2023-10-04 16:00:29 +05:30
Mazen KhalilandGitHub 5c9ab21617 fix: Application not loading after setting RACK_ATTACK_LIMIT ENV variable [CW-2587] (#8044) 2023-10-04 15:56:20 +05:30
Shivam Mishra 93d126af7b chore: add comment for INBOX_FEATURE_MAP 2023-10-04 15:55:54 +05:30
Shivam Mishra 3c6c57ca0e refactor: better variable name 2023-10-04 15:54:48 +05:30
Shivam Mishra b952608eeb feat: disable reply to for all 2023-10-04 15:52:17 +05:30
Shivam Mishra eb6f1e4031 feat: allow reply to for outgoing messages too 2023-10-04 15:51:48 +05:30
Shivam Mishra 94909255a8 test: cover all inboxMixin functions 2023-10-04 15:50:01 +05:30
Shivam Mishra 91788255f2 test: missing tests for line and telegram 2023-10-04 15:30:15 +05:30
Shivam Mishra 515b9ba0b5 refactor: use common config generator 2023-10-04 15:26:34 +05:30
Shivam Mishra e5c5ebc398 test: inboxHasFeature 2023-10-04 15:20:11 +05:30
Shivam Mishra 21212d9bc3 chore: add dummy handler 2023-10-04 12:13:57 +05:30
Shivam Mishra 0af9e974bd feat: add feature flag map for inbox specific features 2023-10-04 12:12:15 +05:30
Shivam Mishra b3141de50b feat: add context menu option for reply to 2023-10-04 12:01:09 +05:30
Shivam Mishra 1da6962467 feat: add copy for reply to 2023-10-04 12:00:43 +05:30
b71a580573 feat: Adds dark theme support for public portal [CW-2525] (#7979)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-04 11:56:00 +05:30
Sojan JoseandGitHub 6a73953003 chore: Add delay before running dataimport job (#8039)
- We have observed some failures for data import jobs in the cloud due to race conditions with job executions and active storage file uploading. This PR adds delays and retries to accommodate that.
2023-10-03 22:18:57 -07:00
Sojan JoseandGitHub 336af1ac9a chore: Create client API conversations with custom attributes (#8040)
- Update client API create conversations endpoint to accept custom attributes as well.
2023-10-03 22:18:35 -07:00
e4de366b8d feat: Add an option to listen to the start conversation click events (#8038)
This PR will add a new event chatwoot:on-start-conversation to the chat widget, which you can listen to and trigger custom interactions on your end.

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-10-03 18:01:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
373c3dcd6c chore(deps): bump postcss from 8.4.30 to 8.4.31 (#8034)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 10:08:18 -07:00
Shivam MishraandGitHub 4b12dd89b0 fix: videojs-record version (#8029) 2023-10-03 20:57:23 +05:30
c19cfeaa81 feat: Adds message signature for new email conversations (#7946)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-10-03 13:45:28 +05:30
fa118ad18c fix: Issue on the status filter field (#7949)
Co-authored-by: AqidaHaidari <42426077+AqidaHaidari@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-10-03 09:20:46 +05:30
249bfe44bb fix: issue of translation for TimeAgo.vue (#7951)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2023-10-03 08:41:30 +05:30
Sojan JoseandGitHub 826d9ec5a7 chore: Refactor Response Bot Data Schema (#8011)
This PR refactors the schema we introduced in #7518 based on the feedback from production tests. Here is the change log

- Decouple Inbox association to a new table inbox_response_sources -> this lets us share the same response source between multiple inboxes
- Add a status field to responses. This ensures that, by default, responses are created in pending status. You can do quality assurance before making them active. In future, this status can be leveraged by the bot to auto-generate response questions from conversations which require a handoff
- Add response_source association to responses and remove hard dependency from response_documents. This lets users write free-form question answers based on conversations, which doesn't necessarily need a response source.
2023-10-01 19:31:38 -07:00
d8b53f5d2f chore: [Snyk] Security upgrade audited from 5.3.3 to 5.4.0 (#8023)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-09-30 19:49:03 -07:00
Jordan BroughandGitHub 08ced452e1 chore: Log errors handled by RequestExceptionHandler (#8013)
- Logging this info is invaluable when using logs to track down the reason why a particular request failed.
2023-09-30 19:46:58 -07:00
24fe3805d8 feat: Slack link unfurling (#7940)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-09-29 19:35:56 +05:30
Sivin VargheseandGitHub 845b0c0a68 fix: Editor file upload browser showing bug (#8019) 2023-09-29 17:53:54 +05:30
101 changed files with 2272 additions and 1176 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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'
+1 -1
View File
@@ -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
View File
@@ -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?
+11
View File
@@ -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;
@@ -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;
@@ -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">
@@ -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>
+3 -7
View File
@@ -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';
+11 -2
View File
@@ -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);
+1
View File
@@ -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
+7
View File
@@ -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
+2 -1
View File
@@ -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
+7 -2
View File
@@ -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"
+5
View File
@@ -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'
+1 -1
View File
@@ -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---------###
+9
View File
@@ -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.
+8 -2
View File
@@ -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?
+1 -1
View File
@@ -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
+11 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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
+60
View File
@@ -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
+14
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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
+3
View File
@@ -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
+19
View File
@@ -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