Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan cd016c5ad4 chore: log webhook response data on error 2023-11-24 14:18:13 +05:30
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan 4244945e7b Bump version to 3.3.1 2023-11-18 10:23:18 -08:00
Clairton Rodrigo HeinzenandGitHub 17725e4dd0 fix: Breakage of message pane when Openai integration isn't enabled (#8381)
Error when the Openai integration isn't enabled

fixes: https://github.com/chatwoot/chatwoot/issues/8379
2023-11-18 10:18:55 -08:00
Sojan d55bb63aa0 Merge branch 'release/3.3.0' into develop 2023-11-17 20:37:44 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 6e22695b85 Bump version to 3.3.0 2023-11-17 20:34:39 -08:00
Pranav Raj SandGitHub 146e46d79f fix: Disable AI Assist CTA if not required (#8375)
- Show CTAs only on production
2023-11-17 20:10:46 -08:00
Nithin David ThomasandGitHub 0af27a2387 feat: Adds support for logo in portal settings page [CW-2585] (#8354) 2023-11-17 19:58:27 -08:00
7380f0e7ce chore: Making OpenAI label suggestions optional (#8374)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-17 19:54:15 -08:00
Pranav Raj SandGitHub 2c5a0abcce fix: Update the height of the input based on the rows (#8373) 2023-11-17 17:29:44 -08:00
Sojan JoseandGitHub f23e33c1a9 perf: Remove unwanted conversation count query (#8372) 2023-11-17 16:40:40 -08:00
5588d6e344 feat: convert feature_flag to bigint [CW-2767] (#8350)
Co-authored-by: Sojan <sojan@pepalo.com>
2023-11-17 15:50:24 -08:00
59ace66c6f chore: [Snyk] Fix for vulnerabilities (#8340)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-17 15:24:00 -08:00
Sojan JoseandGitHub decef1ad52 chore: Add rails_panel gem for query debugging (#8370) 2023-11-18 02:30:35 +05:30
Sojan JoseandGitHub 0e19a4196b chore: Endpoint to purge portal logo (#8365) 2023-11-18 01:13:22 +05:30
Muhsin KelothandGitHub 04dd65687b feat: Changes to include delivery reports for all channels (#8329) 2023-11-16 19:43:35 +05:30
Muhsin KelothandGitHub 5f503b1a57 feat: Change slack bot and activity message appearance (#8349) 2023-11-16 12:59:52 +05:30
Sojan JoseandGitHub 5f6e974531 chore: Add website_token into request logs (#8362)
- Log website_token into request logs to identify specific widgets
2023-11-15 20:32:21 -08:00
Sojan JoseandGitHub a023eabf59 chore: Add logging for facebook errors (#8360)
- Add logging for Facebook errors
2023-11-15 14:56:21 -08:00
Sivin VargheseandGitHub cb1e25a487 feat: Change the highlight in article page on scrolling (#8330) 2023-11-15 13:51:46 +05:30
Sojan JoseandGitHub bcf7c6c45a chore: Improve account seeding (#8357) 2023-11-15 13:29:00 +05:30
Shivam MishraandGitHub b411b6ca21 feat: remove feature flag checks for message reply to (#8352) 2023-11-15 13:05:58 +05:30
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
52 changed files with 423 additions and 155 deletions
+4 -2
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.12.0', require: false gem 'sentry-rails', '>= 5.13.0', require: false
gem 'sentry-ruby', require: false gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.12.0', require: false gem 'sentry-sidekiq', '>= 5.13.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.1.3' gem 'sidekiq', '>= 7.1.3'
@@ -200,6 +200,8 @@ 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
+17 -11
View File
@@ -451,13 +451,16 @@ 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.4) mini_portile2 (2.8.5)
minitest (5.20.0) minitest (5.20.0)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
@@ -555,10 +558,12 @@ 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.1) racc (1.7.3)
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)
@@ -599,7 +604,7 @@ GEM
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.1.0)
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)
@@ -704,13 +709,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.12.0) sentry-rails (5.13.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.12.0) sentry-ruby (~> 5.13.0)
sentry-ruby (5.12.0) sentry-ruby (5.13.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.12.0) sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.12.0) sentry-ruby (~> 5.13.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)
@@ -760,7 +765,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.2.2) thor (1.3.0)
tilt (2.2.0) tilt (2.2.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
@@ -895,6 +900,7 @@ 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)
@@ -929,9 +935,9 @@ DEPENDENCIES
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.12.0) sentry-rails (>= 5.13.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.12.0) sentry-sidekiq (>= 5.13.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.1.3) sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0) sidekiq-cron (>= 1.11.0)
@@ -47,6 +47,11 @@ 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,6 +17,10 @@ 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,3 +16,23 @@
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,6 +61,7 @@ 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;
@@ -70,7 +71,8 @@ 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,7 +114,6 @@ 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';
@@ -285,14 +284,6 @@ 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),
@@ -349,7 +340,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.isAIIntegrationEnabled) { if (!this.isLabelSuggestionFeatureEnabled) {
return; return;
} }
@@ -194,7 +194,6 @@ 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';
@@ -276,11 +275,7 @@ 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,11 +201,12 @@ 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;
} }
// There is no source id for the line channel // All messages will be mark as sent for the Line channel, as there is no source ID.
if (this.isALineChannel) { if (this.isALineChannel) {
return true; return true;
} }
@@ -216,15 +217,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;
} }
// We will consider messages as delivered for web widget inbox and API inbox if they are sent // All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (this.isAWebWidgetInbox || this.isAPIInbox) { if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isSent; return this.isSent;
} }
@@ -238,12 +239,6 @@ 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 ||
@@ -252,6 +247,10 @@ 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,6 +16,5 @@ 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,3 +96,15 @@ 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;
}
};
@@ -4,6 +4,7 @@ import {
isValidURL, isValidURL,
conversationListPageURL, conversationListPageURL,
getArticleSearchURL, getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper'; } from '../URLHelper';
describe('#URL Helpers', () => { describe('#URL Helpers', () => {
@@ -164,4 +165,29 @@ 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,7 +223,10 @@
"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",
+14 -6
View File
@@ -15,18 +15,26 @@ export default {
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode', replyMode: 'draftMessages/getReplyEditorMode',
}), }),
isAIIntegrationEnabled() { aiIntegration() {
return !!this.appIntegrations.find( return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length integration => integration.id === 'openai' && !!integration.hooks.length
); )?.hooks[0];
},
isAIIntegrationEnabled() {
return !!this.aiIntegration;
},
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.appIntegrations.find( return this.aiIntegration.id;
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,6 +12,7 @@
<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,8 +3,7 @@
<resizable-text-area <resizable-text-area
v-model="articleTitle" v-model="articleTitle"
type="text" type="text"
rows="1" :rows="1"
:min-height="6"
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,21 +14,24 @@
> >
<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">
<thumbnail :username="name" size="56px" variant="square" /> <woot-avatar-uploader
<woot-button ref="imageUpload"
v-if="false" :label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
class="ml-3" :src="logoUrl"
variant="smooth" @change="onFileChange"
color-scheme="secondary" />
icon="upload" <div v-if="showDeleteButton" class="avatar-delete-btn">
size="small" <woot-button
> type="button"
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }} color-scheme="alert"
</woot-button> variant="hollow"
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"
@@ -87,17 +90,20 @@
<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 {
components: { mixins: [alertMixin],
thumbnail,
},
props: { props: {
portal: { portal: {
type: Object, type: Object,
@@ -118,6 +124,10 @@ export default {
slug: '', slug: '',
domain: '', domain: '',
alertMessage: '', alertMessage: '',
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
}; };
}, },
validations: { validations: {
@@ -159,6 +169,9 @@ export default {
exampleURL: EXAMPLE_URL, exampleURL: EXAMPLE_URL,
}); });
}, },
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
}, },
mounted() { mounted() {
const portal = this.portal || {}; const portal = this.portal || {};
@@ -166,6 +179,13 @@ 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() {
@@ -181,9 +201,44 @@ 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,6 +7,7 @@
$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>
@@ -70,6 +71,19 @@ 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,9 +40,12 @@ 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'
); );
@@ -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"> <div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header <woot-modal-header
:header-title="integration.name" :header-title="integration.name"
:header-content="integration.description" :header-content="integration.description"
@@ -128,6 +128,7 @@
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';
@@ -198,6 +199,9 @@ 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) {
@@ -265,9 +269,6 @@ 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,6 +2,7 @@ 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 {
@@ -89,6 +90,23 @@ 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,27 +2,21 @@
<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">
<h2 <ol
id="on-this-page-title" role="list"
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="flex flex-col gap-2 text-base 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 py-2 pl-6 border-l-2 border-solid" class="leading-6 border-l-2 relative -left-0.5 border-solid"
:class="elementBorderStyles(element)" :class="elementBorderStyles(element)"
> >
<p :class="getClassName(element)"> <p class="py-2 px-4" :class="getClassName(element)">
<a <a
:href="`#${element.slug}`" :href="`#${element.slug}`"
data-turbolinks="false" data-turbolinks="false"
class="text-base cursor-pointer" class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)" :class="elementTextStyles(element)"
> >
{{ element.title }} {{ element.title }}
@@ -45,12 +39,10 @@ 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;
}, },
@@ -59,10 +51,14 @@ 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) {
@@ -85,18 +81,32 @@ 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';
@@ -105,7 +115,7 @@ export default {
}, },
elementTextStyles(el) { elementTextStyles(el) {
if (this.isElementActive(el)) { if (this.isElementActive(el)) {
return 'font-semibold text-slate-900 dark:text-slate-25'; return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
} }
return 'text-slate-700 dark:text-slate-100'; return 'text-slate-700 dark:text-slate-100';
}, },
@@ -17,9 +17,8 @@
<resizable-text-area <resizable-text-area
v-else v-else
v-model="greetingsMessage" v-model="greetingsMessage"
rows="4" :rows="4"
type="text" type="text"
:min-height="6"
class="greetings--textarea" class="greetings--textarea"
:label="label" :label="label"
:placeholder="placeholder" :placeholder="placeholder"
@@ -1,8 +1,8 @@
<template> <template>
<textarea <textarea
ref="textarea" ref="textarea"
:style="{ height: textareaHeight }"
:placeholder="placeholder" :placeholder="placeholder"
:rows="rows"
:value="value" :value="value"
@input="onInput" @input="onInput"
@focus="onFocus" @focus="onFocus"
@@ -19,7 +19,6 @@ import {
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
const PIXELS_PER_REM = 16;
export default { export default {
props: { props: {
placeholder: { placeholder: {
@@ -38,6 +37,10 @@ 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,
@@ -52,7 +55,6 @@ export default {
data() { data() {
return { return {
idleTimer: null, idleTimer: null,
textareaHeight: `${this.minHeight * PIXELS_PER_REM}px`,
}; };
}, },
computed: { computed: {
@@ -94,16 +96,12 @@ export default {
}, },
methods: { methods: {
resizeTextarea() { resizeTextarea() {
const textarea = this.$refs.textarea; this.$el.style.height = 'auto';
if (!textarea || !this.value) return; if (!this.value) {
// Reset the height to the minimum first to allow shrinking this.$el.style.height = `${this.minHeight}rem`;
const minHeightPx = this.minHeight * PIXELS_PER_REM; } else {
this.textareaHeight = `${minHeightPx}px`; this.$el.style.height = `${this.$el.scrollHeight}px`;
}
this.$nextTick(() => {
const newHeight = Math.max(textarea.scrollHeight, minHeightPx);
this.textareaHeight = `${newHeight}px`; // Update the height
});
}, },
// The toggleSignatureInEditor gets the new value from the // The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature // watcher, this means that if the value is true, the signature
@@ -50,6 +50,7 @@ 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,7 +22,6 @@
<drag-wrapper <drag-wrapper
class="space-y-2" class="space-y-2"
direction="right" direction="right"
:disabled="!allowReplyTo"
@dragged="toggleReply" @dragged="toggleReply"
> >
<AgentMessageBubble <AgentMessageBubble
@@ -58,7 +57,6 @@
</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"
/> />
@@ -127,7 +125,6 @@ export default {
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -217,7 +214,6 @@ 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,7 +68,6 @@ export default {
data() { data() {
return { return {
inReplyTo: null, inReplyTo: null,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -91,8 +90,6 @@ 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,6 +8,7 @@
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,16 +11,12 @@
<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 && allowReplyTo" v-if="!isInProgress && !isFailed"
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 <drag-wrapper direction="left" @dragged="toggleReply">
direction="left"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<user-message-bubble <user-message-bubble
v-if="showTextBubble" v-if="showTextBubble"
:message="message.content" :message="message.content"
@@ -111,7 +107,6 @@ export default {
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
}; };
}, },
computed: { computed: {
@@ -142,7 +137,6 @@ 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 :integer default(0), not null # feature_flags :bigint default(0), not null
# limits :jsonb # limits :jsonb
# locale :integer default("en") # locale :integer default("en")
# name :string not null # name :string not null
+4
View File
@@ -27,6 +27,10 @@ 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,7 +17,10 @@ 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)
message.update!(status: :failed, external_error: external_error(parsed_result)) if parsed_result['error'].present? 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,7 +7,6 @@ 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,6 +1,5 @@
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-center py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800"> <div class="flex justify-between items-start 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"> <span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>
</span> </span>
</div> </div>
-1
View File
@@ -28,7 +28,6 @@
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.2.0' version: '3.3.1'
development: development:
<<: *shared <<: *shared
@@ -2,5 +2,11 @@
# 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, :token, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn :password, :secret, :_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]
+8 -1
View File
@@ -139,6 +139,7 @@ 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,
@@ -150,5 +151,11 @@ 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'] visible_properties: ['api_key', 'label_suggestion']
+1
View File
@@ -217,6 +217,7 @@ 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
@@ -0,0 +1,9 @@
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_10_13_072802) do ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) 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_10_13_072802) 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.integer "feature_flags", default: 0, null: false t.bigint "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,11 +21,7 @@ module Enterprise::Integrations::OpenaiProcessorService
def labels_with_messages def labels_with_messages
conversation = find_conversation conversation = find_conversation
# return nil if conversation is not present return nil unless valid_conversation?(conversation)
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
@@ -38,6 +34,19 @@ 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,
@@ -50,8 +59,10 @@ 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 nil if content.blank? return value_from_cache if content.blank?
{ {
model: self.class::GPT_MODEL, model: self.class::GPT_MODEL,
@@ -64,4 +75,8 @@ 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(message_text) sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
if conversation.identifier.present? if conversation.identifier.present?
"#{private_indicator}#{sanitized_content}" "#{private_indicator}#{sanitized_content}"
@@ -53,6 +53,10 @@ 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')
@@ -79,7 +83,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end end
def avatar_url(sender) def avatar_url(sender)
sender_type = sender.instance_of?(Contact) ? 'contact' : 'user' sender_type = sender_type(sender).downcase
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
@@ -137,7 +141,15 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end end
def sender_type(sender) def sender_type(sender)
sender.instance_of?(Contact) ? 'Contact' : 'Agent' if sender.instance_of?(Contact)
'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
+6 -3
View File
@@ -74,11 +74,14 @@ 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.create!(contact_data.slice('name', 'email')) contact = @account.contacts.find_or_initialize_by(email: contact_data['email'])
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}") if contact.new_record?
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!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex)) contact_inbox = inbox.contact_inboxes.create_or_find_by!(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
+3 -2
View File
@@ -12,10 +12,11 @@ class Webhooks::Trigger
end end
def execute def execute
perform_request response = perform_request
rescue StandardError => e rescue StandardError => e
handle_error(e) handle_error(e)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}" Rails.logger.warn "Webhooks: 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.2.0", "version": "3.3.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -210,4 +210,37 @@ 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: "#{template_message.sender.name} (Contact)", username: 'Bot',
thread_ts: conversation.identifier, thread_ts: conversation.identifier,
icon_url: anything, icon_url: anything,
unfurl_links: true unfurl_links: true
@@ -198,6 +198,24 @@ 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,