Compare commits

..
Author SHA1 Message Date
Shivam Mishra 4a758f1a2b feat: setup algolia 2023-11-13 11:10:10 +05:30
Shivam Mishra d17fa149a6 feat: init algolia 2023-11-11 19:35:30 +05:30
Shivam Mishra 3bde1becbd chore: install algoliasearch-rails 2023-11-11 18:52:59 +05:30
58 changed files with 208 additions and 441 deletions
+5 -4
View File
@@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.13.0', require: false gem 'sentry-rails', '>= 5.12.0', require: false
gem 'sentry-ruby', require: false gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false gem 'sentry-sidekiq', '>= 5.12.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.1.3' gem 'sidekiq', '>= 7.1.3'
@@ -176,6 +176,9 @@ gem 'reverse_markdown'
# Sentiment analysis # Sentiment analysis
gem 'informers' gem 'informers'
# Algolia
gem "algoliasearch-rails"
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
@@ -200,8 +203,6 @@ group :development do
# profiling # profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
end end
group :test do group :test do
+20 -17
View File
@@ -121,6 +121,14 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.1) rails (>= 4.2, < 7.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
algolia (2.3.2)
faraday (>= 0.15, < 3)
faraday-net_http_persistent (>= 0.15, < 3)
multi_json (~> 1.0)
net-http-persistent
algoliasearch-rails (2.3.0)
algolia (< 3.0.0)
json (>= 1.5.1)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -451,16 +459,13 @@ GEM
marcel (1.0.2) marcel (1.0.2)
maxminddb (0.1.22) maxminddb (0.1.22)
memoist (0.16.2) memoist (0.16.2)
meta_request (0.7.4)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 7.1)
method_source (1.0.0) method_source (1.0.0)
mime-types (3.4.1) mime-types (3.4.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.4)
minitest (5.20.0) minitest (5.20.0)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
@@ -558,12 +563,10 @@ GEM
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.7.3) racc (1.7.1)
rack (2.2.8) rack (2.2.8)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.1) rack-cors (2.0.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.1.1) rack-mini-profiler (3.1.1)
@@ -604,7 +607,7 @@ GEM
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.1.0) rake (13.0.6)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
@@ -709,13 +712,13 @@ GEM
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.13.0) sentry-rails (5.12.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.13.0) sentry-ruby (~> 5.12.0)
sentry-ruby (5.13.0) sentry-ruby (5.12.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.13.0) sentry-sidekiq (5.12.0)
sentry-ruby (~> 5.13.0) sentry-ruby (~> 5.12.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.17.0) sexp_processor (4.17.0)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
@@ -765,7 +768,7 @@ GEM
stripe (8.5.0) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.0) thor (1.2.2)
tilt (2.2.0) tilt (2.2.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
@@ -843,6 +846,7 @@ DEPENDENCIES
administrate (>= 0.19.0) administrate (>= 0.19.0)
administrate-field-active_storage administrate-field-active_storage
administrate-field-belongs_to_search administrate-field-belongs_to_search
algoliasearch-rails
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -900,7 +904,6 @@ DEPENDENCIES
listen listen
lograge (~> 0.14.0) lograge (~> 0.14.0)
maxminddb maxminddb
meta_request
mock_redis mock_redis
neighbor neighbor
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
@@ -935,9 +938,9 @@ DEPENDENCIES
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.13.0) sentry-rails (>= 5.12.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.13.0) sentry-sidekiq (>= 5.12.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.1.3) sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0) sidekiq-cron (>= 1.11.0)
+11 -10
View File
@@ -1,12 +1,10 @@
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> <p align="center">
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/> <img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
___ <p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
</p>
# Chatwoot <p align="center">
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku"> <a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/> <img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a> </a>
@@ -15,7 +13,9 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale
</a> </a>
</p> </p>
<p> ___
<p align="center">
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a> <a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
@@ -29,8 +29,9 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a> <a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> <img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile. Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
@@ -47,11 +47,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def logo
@portal.logo.purge if @portal.logo.attached?
head :ok
end
def process_attached_logo def process_attached_logo
blob_id = params[:blob_id] blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id) blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -17,10 +17,6 @@ class PortalsAPI extends ApiClient {
deletePortal(portalSlug) { deletePortal(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}`); return axios.delete(`${this.url}/${portalSlug}`);
} }
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
} }
export default PortalsAPI; export default PortalsAPI;
@@ -16,23 +16,3 @@
width: 100%; width: 100%;
} }
} }
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -61,7 +61,6 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}), }),
isAICTAModalDismissed() { isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed; return this.uiSettings.is_open_ai_cta_modal_dismissed;
@@ -71,8 +70,7 @@ export default {
return ( return (
this.isAdmin && this.isAdmin &&
!this.isAIIntegrationEnabled && !this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed && !this.isAICTAModalDismissed
this.isAChatwootInstance
); );
}, },
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal. // Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
@@ -114,6 +114,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants // constants
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links'; import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -284,6 +285,14 @@ export default {
return this.currentChat.unread_count || 0; return this.currentChat.unread_count || 0;
}, },
inboxSupportsReplyTo() { inboxSupportsReplyTo() {
if (
!this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
) {
return {};
}
return { return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO), incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING), outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
@@ -340,7 +349,7 @@ export default {
// method available in mixin, need to ensure that integrations are present // method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired(); await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) { if (!this.isAIIntegrationEnabled) {
return; return;
} }
@@ -194,6 +194,7 @@ import {
replaceSignature, replaceSignature,
extractTextFromMarkdown, extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
@@ -275,7 +276,11 @@ export default {
return ( return (
this.inReplyTo?.id && this.inReplyTo?.id &&
!this.isPrivate && !this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
); );
}, },
showRichContentEditor() { showRichContentEditor() {
@@ -192,7 +192,7 @@ export default {
if (!this.showStatusIndicators) { if (!this.showStatusIndicators) {
return false; return false;
} }
// Messages will be marked as sent for the Email channel if they have a source ID.
if (this.isAnEmailChannel) { if (this.isAnEmailChannel) {
return !!this.sourceId; return !!this.sourceId;
} }
@@ -201,12 +201,11 @@ export default {
this.isAWhatsAppChannel || this.isAWhatsAppChannel ||
this.isATwilioChannel || this.isATwilioChannel ||
this.isAFacebookInbox || this.isAFacebookInbox ||
this.isASmsInbox || this.isASmsInbox
this.isATelegramChannel
) { ) {
return this.sourceId && this.isSent; return this.sourceId && this.isSent;
} }
// All messages will be mark as sent for the Line channel, as there is no source ID. // There is no source id for the line channel
if (this.isALineChannel) { if (this.isALineChannel) {
return true; return true;
} }
@@ -217,15 +216,15 @@ export default {
if (!this.showStatusIndicators) { if (!this.showStatusIndicators) {
return false; return false;
} }
if ( if (
this.isAWhatsAppChannel || this.isAWhatsAppChannel ||
this.isATwilioChannel || this.isATwilioChannel ||
this.isASmsInbox || this.isASmsInbox
this.isAFacebookInbox
) { ) {
return this.sourceId && this.isDelivered; return this.sourceId && this.isDelivered;
} }
// All messages marked as delivered for the web widget inbox and API inbox once they are sent. // We will consider messages as delivered for web widget inbox and API inbox if they are sent
if (this.isAWebWidgetInbox || this.isAPIInbox) { if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isSent; return this.isSent;
} }
@@ -239,6 +238,12 @@ export default {
if (!this.showStatusIndicators) { if (!this.showStatusIndicators) {
return false; return false;
} }
if (this.isAWebWidgetInbox || this.isAPIInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if ( if (
this.isAWhatsAppChannel || this.isAWhatsAppChannel ||
this.isATwilioChannel || this.isATwilioChannel ||
@@ -247,10 +252,6 @@ export default {
return this.sourceId && this.isRead; return this.sourceId && this.isRead;
} }
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isRead;
}
return false; return false;
}, },
}, },
+1
View File
@@ -16,5 +16,6 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management', TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder', VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs', AUDIT_LOGS: 'audit_logs',
MESSAGE_REPLY_TO: 'message_reply_to',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply', INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
}; };
@@ -96,15 +96,3 @@ export const getArticleSearchURL = ({
return `${host}/${portalSlug}/articles?${queryParams.toString()}`; return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
}; };
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
@@ -135,7 +135,6 @@ export const getActionOptions = ({ agents, teams, labels, type }) => {
assign_team: teams, assign_team: teams,
send_email_to_team: teams, send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'), add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES, change_priority: PRIORITY_CONDITION_VALUES,
}; };
return actionsMap[type]; return actionsMap[type];
@@ -4,7 +4,6 @@ import {
isValidURL, isValidURL,
conversationListPageURL, conversationListPageURL,
getArticleSearchURL, getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper'; } from '../URLHelper';
describe('#URL Helpers', () => { describe('#URL Helpers', () => {
@@ -165,29 +164,4 @@ describe('#URL Helpers', () => {
expect(url).toBe('myurl.com/news/articles?page=1&locale=en'); expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
}); });
}); });
describe('hasValidAvatarUrl', () => {
test('should return true for valid non-Gravatar URL', () => {
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
});
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
false
);
});
test('should return false for a Gravatar URL (gravatar)', () => {
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
});
test('should handle invalid URL', () => {
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
});
test('should return false for empty or undefined URL', () => {
expect(hasValidAvatarUrl('')).toBe(false);
expect(hasValidAvatarUrl()).toBe(false);
});
});
}); });
@@ -223,10 +223,7 @@
"LOGO": { "LOGO": {
"LABEL": "Logo", "LABEL": "Logo",
"UPLOAD_BUTTON": "Upload logo", "UPLOAD_BUTTON": "Upload logo",
"HELP_TEXT": "This logo will be displayed on the portal header.", "HELP_TEXT": "This logo will be displayed on the portal header."
"IMAGE_UPLOAD_SUCCESS": "Logo uploaded successfully",
"IMAGE_UPLOAD_ERROR": "Logo deleted successfully",
"IMAGE_DELETE_ERROR": "Error while deleting logo"
}, },
"NAME": { "NAME": {
"LABEL": "Name", "LABEL": "Name",
+6 -14
View File
@@ -15,26 +15,18 @@ export default {
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode', replyMode: 'draftMessages/getReplyEditorMode',
}), }),
aiIntegration() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0];
},
isAIIntegrationEnabled() { isAIIntegrationEnabled() {
return !!this.aiIntegration; return !!this.appIntegrations.find(
}, integration => integration.id === 'openai' && !!integration.hooks.length
isLabelSuggestionFeatureEnabled() { );
if (this.aiIntegration) {
const { settings = {} } = this.aiIntegration || {};
return settings.label_suggestion;
}
return false;
}, },
isFetchingAppIntegrations() { isFetchingAppIntegrations() {
return this.uiFlags.isFetching; return this.uiFlags.isFetching;
}, },
hookId() { hookId() {
return this.aiIntegration.id; return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
}, },
draftMessage() { draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey); return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -12,7 +12,6 @@
<div v-else class="chat-message-input is-focused"> <div v-else class="chat-message-input is-focused">
<resizable-text-area <resizable-text-area
id="chat-input" id="chat-input"
:rows="1"
:placeholder=" :placeholder="
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER') $t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
" "
@@ -3,7 +3,7 @@
<resizable-text-area <resizable-text-area
v-model="articleTitle" v-model="articleTitle"
type="text" type="text"
:rows="1" rows="1"
class="article-heading" class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')" :placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus" @focus="onFocus"
@@ -14,24 +14,21 @@
> >
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]"> <div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4"> <div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
</label>
<div class="flex items-center flex-row"> <div class="flex items-center flex-row">
<woot-avatar-uploader <thumbnail :username="name" size="56px" variant="square" />
ref="imageUpload" <woot-button
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')" v-if="false"
:src="logoUrl" class="ml-3"
@change="onFileChange" variant="smooth"
/> color-scheme="secondary"
<div v-if="showDeleteButton" class="avatar-delete-btn"> icon="upload"
<woot-button size="small"
type="button" >
color-scheme="alert" {{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
variant="hollow" </woot-button>
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div> </div>
<p <p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic" class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
@@ -90,20 +87,17 @@
<script> <script>
import { required, minLength } from 'vuelidate/lib/validators'; import { required, minLength } from 'vuelidate/lib/validators';
import { isDomain } from 'shared/helpers/Validators'; import { isDomain } from 'shared/helpers/Validators';
import thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { convertToCategorySlug } from 'dashboard/helper/commons.js'; import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper'; import { buildPortalURL } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
const { EXAMPLE_URL } = wootConstants; const { EXAMPLE_URL } = wootConstants;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default { export default {
mixins: [alertMixin], components: {
thumbnail,
},
props: { props: {
portal: { portal: {
type: Object, type: Object,
@@ -124,10 +118,6 @@ export default {
slug: '', slug: '',
domain: '', domain: '',
alertMessage: '', alertMessage: '',
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
}; };
}, },
validations: { validations: {
@@ -169,9 +159,6 @@ export default {
exampleURL: EXAMPLE_URL, exampleURL: EXAMPLE_URL,
}); });
}, },
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
}, },
mounted() { mounted() {
const portal = this.portal || {}; const portal = this.portal || {};
@@ -179,13 +166,6 @@ export default {
this.slug = portal.slug || ''; this.slug = portal.slug || '';
this.domain = portal.custom_domain || ''; this.domain = portal.custom_domain || '';
this.alertMessage = ''; this.alertMessage = '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
this.logoUrl = logoURL;
this.avatarBlobId = blobId;
}
}, },
methods: { methods: {
onNameChange() { onNameChange() {
@@ -201,44 +181,9 @@ export default {
name: this.name, name: this.name,
slug: this.slug, slug: this.slug,
custom_domain: this.domain, custom_domain: this.domain,
blob_id: this.avatarBlobId || null,
}; };
this.$emit('submit', portal); this.$emit('submit', portal);
}, },
async deleteAvatar() {
this.logoUrl = '';
this.avatarBlobId = '';
this.$emit('delete-logo');
},
onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadLogoToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
this.logoUrl = fileUrl;
this.avatarBlobId = blobId;
}
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
);
}
},
}, },
}; };
</script> </script>
@@ -7,7 +7,6 @@
$t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT') $t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT')
" "
@submit="updatePortalSettings" @submit="updatePortalSettings"
@delete-logo="deleteLogo"
/> />
</template> </template>
@@ -71,19 +70,6 @@ export default {
this.showAlert(this.alertMessage); this.showAlert(this.alertMessage);
} }
}, },
async deleteLogo() {
try {
const portalSlug = this.lastPortalSlug;
await this.$store.dispatch('portals/deleteLogo', {
portalSlug,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR');
this.showAlert(this.alertMessage);
}
},
}, },
}; };
</script> </script>
@@ -40,12 +40,9 @@ export default {
methods: { methods: {
async createPortal(portal) { async createPortal(portal) {
try { try {
const { blob_id: blobId } = portal;
await this.$store.dispatch('portals/create', { await this.$store.dispatch('portals/create', {
portal, portal,
blob_id: blobId,
}); });
this.alertMessage = this.$t( this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC' 'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
); );
@@ -67,11 +67,6 @@ export const AUTOMATIONS = {
name: 'Add a label', name: 'Add a label',
attributeI18nKey: 'ADD_LABEL', attributeI18nKey: 'ADD_LABEL',
}, },
{
key: 'remove_label',
name: 'Remove a label',
attributeI18nKey: 'REMOVE_LABEL',
},
{ {
key: 'send_email_to_team', key: 'send_email_to_team',
name: 'Send an email to team', name: 'Send an email to team',
@@ -561,11 +556,6 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'Add a label', label: 'Add a label',
inputType: 'multi_select', inputType: 'multi_select',
}, },
{
key: 'remove_label',
label: 'Remove a label',
inputType: 'multi_select',
},
{ {
key: 'send_email_to_team', key: 'send_email_to_team',
label: 'Send an email to team', label: 'Send an email to team',
@@ -1,6 +1,6 @@
<!-- eslint-disable vue/v-slot-style --> <!-- eslint-disable vue/v-slot-style -->
<template> <template>
<div class="flex flex-col h-auto overflow-auto integration-hooks"> <div class="flex flex-col h-auto overflow-auto">
<woot-modal-header <woot-modal-header
:header-title="integration.name" :header-title="integration.name"
:header-content="integration.description" :header-content="integration.description"
@@ -128,7 +128,6 @@
import { required, minLength, email } from 'vuelidate/lib/validators'; import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api'; import { clearCookiesOnLogout } from '../../../../store/utils/api';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import NotificationSettings from './NotificationSettings.vue'; import NotificationSettings from './NotificationSettings.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword.vue'; import ChangePassword from './ChangePassword.vue';
@@ -199,9 +198,6 @@ export default {
currentUserId: 'getCurrentUserID', currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
}), }),
showDeleteButton() {
return hasValidAvatarUrl(this.avatarUrl);
},
}, },
watch: { watch: {
currentUserId(newCurrentUserId, prevCurrentUserId) { currentUserId(newCurrentUserId, prevCurrentUserId) {
@@ -269,6 +265,9 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED')); this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
} }
}, },
showDeleteButton() {
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
},
toggleEditorMessageKey(key) { toggleEditorMessageKey(key) {
this.updateUISettings({ editor_message_key: key }); this.updateUISettings({ editor_message_key: key });
this.showAlert( this.showAlert(
@@ -2,7 +2,6 @@ import PortalAPI from 'dashboard/api/helpCenter/portals';
import { throwErrorMessage } from 'dashboard/store/utils/api'; import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations'; import { types } from './mutations';
const portalAPIs = new PortalAPI(); const portalAPIs = new PortalAPI();
export const actions = { export const actions = {
index: async ({ commit }) => { index: async ({ commit }) => {
try { try {
@@ -90,23 +89,6 @@ export const actions = {
} }
}, },
deleteLogo: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalSlug,
});
try {
await portalAPIs.deleteLogo(portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalSlug,
});
}
},
updatePortal: async ({ commit }, portal) => { updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal); commit(types.UPDATE_PORTAL_ENTRY, portal);
}, },
@@ -2,21 +2,27 @@
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4"> <div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4">
<div v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto"> <div v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto">
<nav class="max-w-2xl"> <nav class="max-w-2xl">
<ol <h2
role="list" id="on-this-page-title"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800" class="text-slate-800 pl-6 dark:text-slate-50 font-semibold tracking-wide py-3 leading-7 border-l-2 border-solid border-slate-100 dark:border-slate-800"
:class="{
'!border-slate-400 dark:!border-slate-100': !currentSlug,
}"
> >
{{ tocHeader }}
</h2>
<ol role="list" class="text-base">
<li <li
v-for="element in rows" v-for="element in rows"
:key="element.slug" :key="element.slug"
class="leading-6 border-l-2 relative -left-0.5 border-solid" class="leading-6 py-2 pl-6 border-l-2 border-solid"
:class="elementBorderStyles(element)" :class="elementBorderStyles(element)"
> >
<p class="py-2 px-4" :class="getClassName(element)"> <p :class="getClassName(element)">
<a <a
:href="`#${element.slug}`" :href="`#${element.slug}`"
data-turbolinks="false" data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer" class="text-base cursor-pointer"
:class="elementTextStyles(element)" :class="elementTextStyles(element)"
> >
{{ element.title }} {{ element.title }}
@@ -39,10 +45,12 @@ export default {
data() { data() {
return { return {
currentSlug: window.location?.hash?.substring(1) || '', currentSlug: window.location?.hash?.substring(1) || '',
intersectionObserver: null,
}; };
}, },
computed: { computed: {
tocHeader() {
return window.portalConfig.tocHeader;
},
h1Count() { h1Count() {
return this.rows.filter(el => el.tag === 'h1').length; return this.rows.filter(el => el.tag === 'h1').length;
}, },
@@ -51,14 +59,10 @@ export default {
}, },
}, },
mounted() { mounted() {
this.initializeIntersectionObserver();
window.addEventListener('hashchange', this.onURLHashChange); window.addEventListener('hashchange', this.onURLHashChange);
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('hashchange', this.onURLHashChange); window.removeEventListener('hashchange', this.onURLHashChange);
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
}, },
methods: { methods: {
getClassName(el) { getClassName(el) {
@@ -81,32 +85,18 @@ export default {
return ''; return '';
}, },
initializeIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(
entries => {
const currentSection = entries.find(entry => entry.isIntersecting);
if (currentSection) {
this.currentSlug = currentSection.target.id;
}
},
{
threshold: 0.25,
rootMargin: '0px 0px -20% 0px',
}
);
this.rows.forEach(el => {
const sectionElement = document.getElementById(el.slug);
if (!sectionElement) return;
this.intersectionObserver.observe(sectionElement);
});
},
onURLHashChange() { onURLHashChange() {
this.currentSlug = window.location?.hash?.substring(1) || ''; this.currentSlug = window.location?.hash?.substring(1) || '';
}, },
isElementActive(el) { isElementActive(el) {
return this.currentSlug === el.slug; return this.currentSlug === el.slug;
}, },
tocHeaderBorderStyles() {
if (this.currentSlug === '') {
return 'border-slate-400 dark:border-slate-100';
}
return 'border-slate-100 dark:border-slate-800';
},
elementBorderStyles(el) { elementBorderStyles(el) {
if (this.isElementActive(el)) { if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200'; return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
@@ -115,7 +105,7 @@ export default {
}, },
elementTextStyles(el) { elementTextStyles(el) {
if (this.isElementActive(el)) { if (this.isElementActive(el)) {
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200'; return 'font-semibold text-slate-900 dark:text-slate-25';
} }
return 'text-slate-700 dark:text-slate-100'; return 'text-slate-700 dark:text-slate-100';
}, },
@@ -17,7 +17,7 @@
<resizable-text-area <resizable-text-area
v-else v-else
v-model="greetingsMessage" v-model="greetingsMessage"
:rows="4" rows="4"
type="text" type="text"
class="greetings--textarea" class="greetings--textarea"
:label="label" :label="label"
@@ -2,7 +2,6 @@
<textarea <textarea
ref="textarea" ref="textarea"
:placeholder="placeholder" :placeholder="placeholder"
:rows="rows"
:value="value" :value="value"
@input="onInput" @input="onInput"
@focus="onFocus" @focus="onFocus"
@@ -37,10 +36,6 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
rows: {
type: Number,
default: 2,
},
// add this as a prop, so that we won't have to include uiSettingsMixin // add this as a prop, so that we won't have to include uiSettingsMixin
sendWithSignature: { sendWithSignature: {
type: Boolean, type: Boolean,
@@ -50,7 +50,6 @@ export const getters = {
get: $state => $state, get: $state => $state,
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud', isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot', isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot',
isAChatwootInstance: $state => $state.installationName === 'Chatwoot',
}; };
export const actions = {}; export const actions = {};
@@ -22,6 +22,7 @@
<drag-wrapper <drag-wrapper
class="space-y-2" class="space-y-2"
direction="right" direction="right"
:disabled="!allowReplyTo"
@dragged="toggleReply" @dragged="toggleReply"
> >
<AgentMessageBubble <AgentMessageBubble
@@ -57,6 +58,7 @@
</drag-wrapper> </drag-wrapper>
<div class="flex flex-col justify-end"> <div class="flex flex-col justify-end">
<message-reply-button <message-reply-button
v-if="allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0" class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply" @click="toggleReply"
/> />
@@ -125,6 +127,7 @@ export default {
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -214,6 +217,7 @@ export default {
}; };
}, },
hasReplyTo() { hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments); return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
}, },
}, },
@@ -68,6 +68,7 @@ export default {
data() { data() {
return { return {
inReplyTo: null, inReplyTo: null,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -90,6 +91,8 @@ export default {
return this.currentUser && this.currentUser.email; return this.currentUser && this.currentUser.email;
}, },
hasReplyTo() { hasReplyTo() {
if (!this.allowReplyTo) return false;
return ( return (
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments) this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
); );
@@ -8,7 +8,6 @@
id="chat-input" id="chat-input"
ref="chatInput" ref="chatInput"
v-model="userInput" v-model="userInput"
:rows="1"
:aria-label="$t('CHAT_PLACEHOLDER')" :aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')" :placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input is-focused" class="form-input user-message-input is-focused"
@@ -11,12 +11,16 @@
<div class="flex justify-end gap-1"> <div class="flex justify-end gap-1">
<div class="flex flex-col justify-end"> <div class="flex flex-col justify-end">
<message-reply-button <message-reply-button
v-if="!isInProgress && !isFailed" v-if="!isInProgress && !isFailed && allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0" class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply" @click="toggleReply"
/> />
</div> </div>
<drag-wrapper direction="left" @dragged="toggleReply"> <drag-wrapper
direction="left"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<user-message-bubble <user-message-bubble
v-if="showTextBubble" v-if="showTextBubble"
:message="message.content" :message="message.content"
@@ -107,6 +111,7 @@ export default {
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -137,6 +142,7 @@ export default {
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE'); : this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
}, },
hasReplyTo() { hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments); return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
}, },
}, },
+1 -1
View File
@@ -6,7 +6,7 @@
# auto_resolve_duration :integer # auto_resolve_duration :integer
# custom_attributes :jsonb # custom_attributes :jsonb
# domain :string(100) # domain :string(100)
# feature_flags :bigint default(0), not null # feature_flags :integer default(0), not null
# limits :jsonb # limits :jsonb
# locale :integer default("en") # locale :integer default("en")
# name :string not null # name :string not null
+39 -1
View File
@@ -28,6 +28,7 @@
# #
class Article < ApplicationRecord class Article < ApplicationRecord
include PgSearch::Model include PgSearch::Model
include AlgoliaSearch
has_many :associated_articles, has_many :associated_articles,
class_name: :Article, class_name: :Article,
@@ -82,7 +83,32 @@ class Article < ApplicationRecord
} }
) )
def self.search(params) algoliasearch per_environment: true do
# the list of attributes sent to Algolia's API
attribute :created_at, :title, :content, :description
add_attribute :tenant_id
# searchableAttributes ['title', 'description', 'content']
searchableAttributes %w[title description content]
# integer version of the created_at datetime field, to use numerical filtering
attribute :created_at_i do
created_at.to_i
end
end
def tenant_id
"cw-tenant-#{account.id}-#{portal.id}"
end
def self.search_with_algolia(params)
results = raw_search(params[:query])
hit_ids = results['hits'].pluck('objectID')
Article.where(id: hit_ids)
end
def self.search_on_db(params)
records = joins( records = joins(
:category :category
).search_by_category_slug( ).search_by_category_slug(
@@ -93,6 +119,18 @@ class Article < ApplicationRecord
records records
end end
def self.search(params)
if algolia_enabled?
search_with_algolia(params)
else
search_on_db(params)
end
end
def self.algolia_enabled?
ENV['ALGOLIA_KEY'].present? && ENV['ALGOLIA_APP_ID'].present?
end
def associate_root_article(associated_article_id) def associate_root_article(associated_article_id)
article = portal.articles.find(associated_article_id) if associated_article_id.present? article = portal.articles.find(associated_article_id) if associated_article_id.present?
+2 -2
View File
@@ -32,8 +32,8 @@ class AutomationRule < ApplicationRecord
CONDITIONS_ATTRS = %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id CONDITIONS_ATTRS = %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language].freeze mail_subject phone_number priority conversation_language].freeze
ACTIONS_ATTRS = %w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation send_attachment
send_attachment change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze
def file_base_data def file_base_data
files.map do |file| files.map do |file|
-4
View File
@@ -27,10 +27,6 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
def logo?
@account_user.administrator?
end
private private
def portal_member? def portal_member?
@@ -17,10 +17,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
def send_message_to_facebook(delivery_params) def send_message_to_facebook(delivery_params)
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id) result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
parsed_result = JSON.parse(result) parsed_result = JSON.parse(result)
if parsed_result['error'].present? message.update!(status: :failed, external_error: external_error(parsed_result)) if parsed_result['error'].present?
message.update!(status: :failed, external_error: external_error(parsed_result))
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{result}"
end
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present? message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
end end
@@ -7,6 +7,7 @@ json.phone_number resource.phone_number
json.identifier resource.identifier json.identifier resource.identifier
json.thumbnail resource.avatar_url json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes json.custom_attributes resource.custom_attributes
json.conversations_count resource.conversations_count if resource[:conversations_count].present?
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present? json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
json.created_at resource.created_at.to_i if resource[:created_at].present? json.created_at resource.created_at.to_i if resource[:created_at].present?
# we only want to output contact inbox when its /contacts endpoints # we only want to output contact inbox when its /contacts endpoints
@@ -1,5 +1,6 @@
json.website_channel_config do json.website_channel_config do
json.allow_messages_after_resolved @web_widget.inbox.allow_messages_after_resolved json.allow_messages_after_resolved @web_widget.inbox.allow_messages_after_resolved
json.allow_reply_to @web_widget.inbox.account.feature_enabled?('message_reply_to')
json.api_host ENV.fetch('FRONTEND_URL', nil) json.api_host ENV.fetch('FRONTEND_URL', nil)
json.auth_token @token json.auth_token @token
json.avatar_url @web_widget.inbox.avatar_url json.avatar_url @web_widget.inbox.avatar_url
@@ -14,9 +14,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2">
<% featured_articles.each do |article| %> <% featured_articles.each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"> <a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
<div class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800"> <div class="flex justify-between items-center py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal mt-1.5"> <span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>
</span> </span>
</div> </div>
+1
View File
@@ -28,6 +28,7 @@
timezone: '<%= @web_widget.inbox.timezone %>', timezone: '<%= @web_widget.inbox.timezone %>',
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>, allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>, disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>,
allowReplyTo: <%= @web_widget.inbox.account.feature_enabled?('message_reply_to') %>
} }
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>' window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
window.authToken = '<%= @token %>' window.authToken = '<%= @token %>'
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '3.3.1' version: '3.2.0'
development: development:
<<: *shared <<: *shared
+4
View File
@@ -0,0 +1,4 @@
AlgoliaSearch.configuration = {
application_id: ENV.fetch('ALGOLIA_APP_ID', nil),
api_key: ENV.fetch('ALGOLIA_KEY', nil)
}
@@ -2,11 +2,5 @@
# Configure sensitive parameters which will be filtered from the log file. # Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn :password, :secret, :token, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
] ]
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
filter_regex = /\A(?!.*\bwebsite_token\b).*token/i
# Apply the regex for filtering
Rails.application.config.filter_parameters += [filter_regex]
+1 -8
View File
@@ -139,7 +139,6 @@ openai:
"type": "object", "type": "object",
"properties": { "properties": {
"api_key": { "type": "string" }, "api_key": { "type": "string" },
"label_suggestion": { "type": "boolean" },
}, },
"required": ["api_key"], "required": ["api_key"],
"additionalProperties": false, "additionalProperties": false,
@@ -151,11 +150,5 @@ openai:
"name": "api_key", "name": "api_key",
"validation": "required", "validation": "required",
}, },
{
"label": "Show label suggestions",
"type": "checkbox",
"name": "label_suggestion",
"validation": "",
},
] ]
visible_properties: ['api_key', 'label_suggestion'] visible_properties: ['api_key']
-1
View File
@@ -217,7 +217,6 @@ Rails.application.routes.draw do
member do member do
patch :archive patch :archive
put :add_members put :add_members
delete :logo
end end
resources :categories resources :categories
resources :articles do resources :articles do
@@ -1,9 +0,0 @@
class ChangeFeatureFlagsToBigintInAccounts < ActiveRecord::Migration[7.0]
def up
change_column :accounts, :feature_flags, :bigint
end
def down
change_column :accounts, :feature_flags, :integer
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do ActiveRecord::Schema[7.0].define(version: 2023_10_13_072802) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -49,7 +49,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do
t.integer "locale", default: 0 t.integer "locale", default: 0
t.string "domain", limit: 100 t.string "domain", limit: 100
t.string "support_email", limit: 100 t.string "support_email", limit: 100
t.bigint "feature_flags", default: 0, null: false t.integer "feature_flags", default: 0, null: false
t.integer "auto_resolve_duration" t.integer "auto_resolve_duration"
t.jsonb "limits", default: {} t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
@@ -21,7 +21,11 @@ module Enterprise::Integrations::OpenaiProcessorService
def labels_with_messages def labels_with_messages
conversation = find_conversation conversation = find_conversation
return nil unless valid_conversation?(conversation) # return nil if conversation is not present
return nil if conversation.nil?
# return nil if conversation has less than 3 incoming messages
return nil if conversation.messages.incoming.count < 3
labels = hook.account.labels.pluck(:title).join(', ') labels = hook.account.labels.pluck(:title).join(', ')
character_count = labels.length character_count = labels.length
@@ -34,19 +38,6 @@ module Enterprise::Integrations::OpenaiProcessorService
"Messages:\n#{messages}\nLabels:\n#{labels}" "Messages:\n#{messages}\nLabels:\n#{labels}"
end end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
# Think Mark think, at this point the conversation is beyond saving
return false if conversation.messages.count > 100
# if there are more than 20 messages, only trigger this if the last message is from the client
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def summarize_body def summarize_body
{ {
model: self.class::GPT_MODEL, model: self.class::GPT_MODEL,
@@ -59,10 +50,8 @@ module Enterprise::Integrations::OpenaiProcessorService
end end
def label_suggestion_body def label_suggestion_body
return unless label_suggestions_enabled?
content = labels_with_messages content = labels_with_messages
return value_from_cache if content.blank? return nil if content.blank?
{ {
model: self.class::GPT_MODEL, model: self.class::GPT_MODEL,
@@ -75,8 +64,4 @@ module Enterprise::Integrations::OpenaiProcessorService
] ]
}.to_json }.to_json
end end
def label_suggestions_enabled?
hook.settings['label_suggestion'].present?
end
end end
@@ -44,7 +44,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def message_content def message_content
private_indicator = message.private? ? 'private: ' : '' private_indicator = message.private? ? 'private: ' : ''
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content) sanitized_content = ActionView::Base.full_sanitizer.sanitize(message_text)
if conversation.identifier.present? if conversation.identifier.present?
"#{private_indicator}#{sanitized_content}" "#{private_indicator}#{sanitized_content}"
@@ -53,10 +53,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end end
end end
def format_message_content
message.message_type == 'activity' ? "_#{message_text}_" : message_text
end
def message_text def message_text
if message.content.present? if message.content.present?
message.content.gsub(MENTION_REGEX, '\1') message.content.gsub(MENTION_REGEX, '\1')
@@ -83,7 +79,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end end
def avatar_url(sender) def avatar_url(sender)
sender_type = sender_type(sender).downcase sender_type = sender.instance_of?(Contact) ? 'contact' : 'user'
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
generate_url(sender_type, blob_key) generate_url(sender_type, blob_key)
end end
@@ -141,15 +137,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end end
def sender_type(sender) def sender_type(sender)
if sender.instance_of?(Contact) sender.instance_of?(Contact) ? 'Contact' : 'Agent'
'Contact'
elsif message.message_type == 'template' && sender.nil?
'Bot'
elsif message.message_type == 'activity' && sender.nil?
'System'
else
'Agent'
end
end end
def update_reference_id def update_reference_id
+3 -6
View File
@@ -74,14 +74,11 @@ class Seeders::AccountSeeder
def seed_contacts def seed_contacts
@account_data['contacts'].each do |contact_data| @account_data['contacts'].each do |contact_data|
contact = @account.contacts.find_or_initialize_by(email: contact_data['email']) contact = @account.contacts.create!(contact_data.slice('name', 'email'))
if contact.new_record? Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
contact.update!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
end
contact_data['conversations'].each do |conversation_data| contact_data['conversations'].each do |conversation_data|
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel']) inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
contact_inbox = inbox.contact_inboxes.create_or_find_by!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex)) contact_inbox = inbox.contact_inboxes.create!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data) create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
end end
end end
+2 -3
View File
@@ -12,11 +12,10 @@ class Webhooks::Trigger
end end
def execute def execute
response = perform_request perform_request
rescue StandardError => e rescue StandardError => e
handle_error(e) handle_error(e)
Rails.logger.warn "Webhooks: Exception: Invalid webhook URL #{@url} : #{e.message}" Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}"
Rails.logger.warn "Webhooks: Response body: #{response.body}" if response.respond_to?(:body)
end end
private private
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "3.3.1", "version": "3.2.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -210,37 +210,4 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end end
end end
end end
describe 'DELETE /api/v1/accounts/{account.id}/portals/{portal.slug}/logo' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
before do
portal.logo.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
it 'throw error if agent' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'delete portal logo if admin' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo",
headers: admin.create_new_auth_token,
as: :json
expect { portal.logo.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(response).to have_http_status(:success)
end
end
end
end end
@@ -183,11 +183,11 @@ describe Integrations::Slack::SendOnSlackService do
it 'sent a template message on slack' do it 'sent a template message on slack' do
builder = described_class.new(message: template_message, hook: hook) builder = described_class.new(message: template_message, hook: hook)
allow(builder).to receive(:slack_client).and_return(slack_client) allow(builder).to receive(:slack_client).and_return(slack_client)
template_message.update!(sender: nil)
expect(slack_client).to receive(:chat_postMessage).with( expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id, channel: hook.reference_id,
text: template_message.content, text: template_message.content,
username: 'Bot', username: "#{template_message.sender.name} (Contact)",
thread_ts: conversation.identifier, thread_ts: conversation.identifier,
icon_url: anything, icon_url: anything,
unfurl_links: true unfurl_links: true
@@ -198,24 +198,6 @@ describe Integrations::Slack::SendOnSlackService do
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345' expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
end end
it 'sent a activity message on slack' do
template_message.update!(message_type: :activity)
template_message.update!(sender: nil)
builder = described_class.new(message: template_message, hook: hook)
allow(builder).to receive(:slack_client).and_return(slack_client)
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,
text: "_#{template_message.content}_",
username: 'System',
thread_ts: conversation.identifier,
icon_url: anything,
unfurl_links: true
).and_return(slack_message)
builder.perform
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
end
it 'disables hook on Slack AccountInactive error' do it 'disables hook on Slack AccountInactive error' do
expect(slack_client).to receive(:chat_postMessage).with( expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id, channel: hook.reference_id,