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 417 additions and 140 deletions
+4 -2
View File
@@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', 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-sidekiq', '>= 5.12.0', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
@@ -200,6 +200,8 @@ group :development do
# profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
end
group :test do
+17 -11
View File
@@ -451,13 +451,16 @@ GEM
marcel (1.0.2)
maxminddb (0.1.22)
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)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
mini_portile2 (2.8.5)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
@@ -555,10 +558,12 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.1)
racc (1.7.3)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.1.1)
@@ -599,7 +604,7 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
rake (13.1.0)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
@@ -704,13 +709,13 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.12.0)
sentry-rails (5.13.0)
railties (>= 5.0)
sentry-ruby (~> 5.12.0)
sentry-ruby (5.12.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.12.0)
sentry-ruby (~> 5.12.0)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -760,7 +765,7 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.2)
thor (1.3.0)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
@@ -895,6 +900,7 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request
mock_redis
neighbor
newrelic-sidekiq-metrics (>= 1.6.2)
@@ -929,9 +935,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.12.0)
sentry-rails (>= 5.13.0)
sentry-ruby
sentry-sidekiq (>= 5.12.0)
sentry-sidekiq (>= 5.13.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0)
@@ -47,6 +47,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def logo
@portal.logo.purge if @portal.logo.attached?
head :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -17,6 +17,10 @@ class PortalsAPI extends ApiClient {
deletePortal(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}`);
}
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
}
export default PortalsAPI;
@@ -16,3 +16,23 @@
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: {
...mapGetters({
currentChat: 'getSelectedChat',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
@@ -70,7 +71,8 @@ export default {
return (
this.isAdmin &&
!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.
@@ -114,7 +114,6 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -285,14 +284,6 @@ export default {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
if (
!this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
) {
return {};
}
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
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
await this.fetchIntegrationsIfRequired();
if (!this.isAIIntegrationEnabled) {
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
@@ -194,7 +194,6 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -276,11 +275,7 @@ export default {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
);
},
showRichContentEditor() {
@@ -192,7 +192,7 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
// Messages will be marked as sent for the Email channel if they have a source ID.
if (this.isAnEmailChannel) {
return !!this.sourceId;
}
@@ -201,11 +201,12 @@ export default {
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isAFacebookInbox ||
this.isASmsInbox
this.isASmsInbox ||
this.isATelegramChannel
) {
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) {
return true;
}
@@ -216,15 +217,15 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isASmsInbox
this.isASmsInbox ||
this.isAFacebookInbox
) {
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) {
return this.isSent;
}
@@ -238,12 +239,6 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
@@ -252,6 +247,10 @@ export default {
return this.sourceId && this.isRead;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isRead;
}
return false;
},
},
-1
View File
@@ -16,6 +16,5 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
MESSAGE_REPLY_TO: 'message_reply_to',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
};
@@ -96,3 +96,15 @@ export const getArticleSearchURL = ({
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,
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -164,4 +165,29 @@ describe('#URL Helpers', () => {
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": {
"LABEL": "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": {
"LABEL": "Name",
+14 -6
View File
@@ -15,18 +15,26 @@ export default {
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
isAIIntegrationEnabled() {
return !!this.appIntegrations.find(
aiIntegration() {
return this.appIntegrations.find(
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() {
return this.uiFlags.isFetching;
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
return this.aiIntegration.id;
},
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -12,6 +12,7 @@
<div v-else class="chat-message-input is-focused">
<resizable-text-area
id="chat-input"
:rows="1"
:placeholder="
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
"
@@ -3,7 +3,7 @@
<resizable-text-area
v-model="articleTitle"
type="text"
rows="1"
:rows="1"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus"
@@ -14,21 +14,24 @@
>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
</label>
<div class="flex items-center flex-row">
<thumbnail :username="name" size="56px" variant="square" />
<woot-button
v-if="false"
class="ml-3"
variant="smooth"
color-scheme="secondary"
icon="upload"
size="small"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
</woot-button>
<woot-avatar-uploader
ref="imageUpload"
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="logoUrl"
@change="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div>
<p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
@@ -87,17 +90,20 @@
<script>
import { required, minLength } from 'vuelidate/lib/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 { buildPortalURL } from 'dashboard/helper/portalHelper';
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 MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default {
components: {
thumbnail,
},
mixins: [alertMixin],
props: {
portal: {
type: Object,
@@ -118,6 +124,10 @@ export default {
slug: '',
domain: '',
alertMessage: '',
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
};
},
validations: {
@@ -159,6 +169,9 @@ export default {
exampleURL: EXAMPLE_URL,
});
},
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
},
mounted() {
const portal = this.portal || {};
@@ -166,6 +179,13 @@ export default {
this.slug = portal.slug || '';
this.domain = portal.custom_domain || '';
this.alertMessage = '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
this.logoUrl = logoURL;
this.avatarBlobId = blobId;
}
},
methods: {
onNameChange() {
@@ -181,9 +201,44 @@ export default {
name: this.name,
slug: this.slug,
custom_domain: this.domain,
blob_id: this.avatarBlobId || null,
};
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>
@@ -7,6 +7,7 @@
$t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT')
"
@submit="updatePortalSettings"
@delete-logo="deleteLogo"
/>
</template>
@@ -70,6 +71,19 @@ export default {
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>
@@ -40,9 +40,12 @@ export default {
methods: {
async createPortal(portal) {
try {
const { blob_id: blobId } = portal;
await this.$store.dispatch('portals/create', {
portal,
blob_id: blobId,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
);
@@ -1,6 +1,6 @@
<!-- eslint-disable vue/v-slot-style -->
<template>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
@@ -128,6 +128,7 @@
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import NotificationSettings from './NotificationSettings.vue';
import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword.vue';
@@ -198,6 +199,9 @@ export default {
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
showDeleteButton() {
return hasValidAvatarUrl(this.avatarUrl);
},
},
watch: {
currentUserId(newCurrentUserId, prevCurrentUserId) {
@@ -265,9 +269,6 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
showDeleteButton() {
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
},
toggleEditorMessageKey(key) {
this.updateUISettings({ editor_message_key: key });
this.showAlert(
@@ -2,6 +2,7 @@ import PortalAPI from 'dashboard/api/helpCenter/portals';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations';
const portalAPIs = new PortalAPI();
export const actions = {
index: async ({ commit }) => {
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) => {
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 v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto">
<nav class="max-w-2xl">
<h2
id="on-this-page-title"
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,
}"
<ol
role="list"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
>
{{ tocHeader }}
</h2>
<ol role="list" class="text-base">
<li
v-for="element in rows"
: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)"
>
<p :class="getClassName(element)">
<p class="py-2 px-4" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="text-base cursor-pointer"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
@@ -45,12 +39,10 @@ export default {
data() {
return {
currentSlug: window.location?.hash?.substring(1) || '',
intersectionObserver: null,
};
},
computed: {
tocHeader() {
return window.portalConfig.tocHeader;
},
h1Count() {
return this.rows.filter(el => el.tag === 'h1').length;
},
@@ -59,10 +51,14 @@ export default {
},
},
mounted() {
this.initializeIntersectionObserver();
window.addEventListener('hashchange', this.onURLHashChange);
},
beforeDestroy() {
window.removeEventListener('hashchange', this.onURLHashChange);
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
},
methods: {
getClassName(el) {
@@ -85,18 +81,32 @@ export default {
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() {
this.currentSlug = window.location?.hash?.substring(1) || '';
},
isElementActive(el) {
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) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
@@ -105,7 +115,7 @@ export default {
},
elementTextStyles(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';
},
@@ -17,7 +17,7 @@
<resizable-text-area
v-else
v-model="greetingsMessage"
rows="4"
:rows="4"
type="text"
class="greetings--textarea"
:label="label"
@@ -2,6 +2,7 @@
<textarea
ref="textarea"
:placeholder="placeholder"
:rows="rows"
:value="value"
@input="onInput"
@focus="onFocus"
@@ -36,6 +37,10 @@ export default {
type: String,
default: '',
},
rows: {
type: Number,
default: 2,
},
// add this as a prop, so that we won't have to include uiSettingsMixin
sendWithSignature: {
type: Boolean,
@@ -50,6 +50,7 @@ export const getters = {
get: $state => $state,
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot',
isAChatwootInstance: $state => $state.installationName === 'Chatwoot',
};
export const actions = {};
@@ -22,7 +22,6 @@
<drag-wrapper
class="space-y-2"
direction="right"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<AgentMessageBubble
@@ -58,7 +57,6 @@
</drag-wrapper>
<div class="flex flex-col justify-end">
<message-reply-button
v-if="allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply"
/>
@@ -127,7 +125,6 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -217,7 +214,6 @@ export default {
};
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
@@ -68,7 +68,6 @@ export default {
data() {
return {
inReplyTo: null,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -91,8 +90,6 @@ export default {
return this.currentUser && this.currentUser.email;
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return (
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
);
@@ -8,6 +8,7 @@
id="chat-input"
ref="chatInput"
v-model="userInput"
:rows="1"
:aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input is-focused"
@@ -11,16 +11,12 @@
<div class="flex justify-end gap-1">
<div class="flex flex-col justify-end">
<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"
@click="toggleReply"
/>
</div>
<drag-wrapper
direction="left"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<drag-wrapper direction="left" @dragged="toggleReply">
<user-message-bubble
v-if="showTextBubble"
:message="message.content"
@@ -111,7 +107,6 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -142,7 +137,6 @@ export default {
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
+1 -1
View File
@@ -6,7 +6,7 @@
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :integer default(0), not null
# feature_flags :bigint default(0), not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
+4
View File
@@ -27,6 +27,10 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
def logo?
@account_user.administrator?
end
private
def portal_member?
@@ -17,7 +17,10 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
def send_message_to_facebook(delivery_params)
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
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?
end
@@ -7,7 +7,6 @@ json.phone_number resource.phone_number
json.identifier resource.identifier
json.thumbnail resource.avatar_url
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.created_at resource.created_at.to_i if resource[:created_at].present?
# we only want to output contact inbox when its /contacts endpoints
@@ -1,6 +1,5 @@
json.website_channel_config do
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.auth_token @token
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">
<% 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) %>">
<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 %>
<span class="flex items-center font-normal">
<span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
-1
View File
@@ -28,7 +28,6 @@
timezone: '<%= @web_widget.inbox.timezone %>',
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
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.authToken = '<%= @token %>'
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.2.0'
version: '3.3.1'
development:
<<: *shared
@@ -2,5 +2,11 @@
# Configure sensitive parameters which will be filtered from the log file.
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",
"properties": {
"api_key": { "type": "string" },
"label_suggestion": { "type": "boolean" },
},
"required": ["api_key"],
"additionalProperties": false,
@@ -150,5 +151,11 @@ openai:
"name": "api_key",
"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
patch :archive
put :add_members
delete :logo
end
resources :categories
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.
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
enable_extension "pg_stat_statements"
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.string "domain", 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.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
@@ -21,11 +21,7 @@ module Enterprise::Integrations::OpenaiProcessorService
def labels_with_messages
conversation = find_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
return nil unless valid_conversation?(conversation)
labels = hook.account.labels.pluck(:title).join(', ')
character_count = labels.length
@@ -38,6 +34,19 @@ module Enterprise::Integrations::OpenaiProcessorService
"Messages:\n#{messages}\nLabels:\n#{labels}"
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
{
model: self.class::GPT_MODEL,
@@ -50,8 +59,10 @@ module Enterprise::Integrations::OpenaiProcessorService
end
def label_suggestion_body
return unless label_suggestions_enabled?
content = labels_with_messages
return nil if content.blank?
return value_from_cache if content.blank?
{
model: self.class::GPT_MODEL,
@@ -64,4 +75,8 @@ module Enterprise::Integrations::OpenaiProcessorService
]
}.to_json
end
def label_suggestions_enabled?
hook.settings['label_suggestion'].present?
end
end
@@ -44,7 +44,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def message_content
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?
"#{private_indicator}#{sanitized_content}"
@@ -53,6 +53,10 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
end
def format_message_content
message.message_type == 'activity' ? "_#{message_text}_" : message_text
end
def message_text
if message.content.present?
message.content.gsub(MENTION_REGEX, '\1')
@@ -79,7 +83,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
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
generate_url(sender_type, blob_key)
end
@@ -137,7 +141,15 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
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
def update_reference_id
+6 -3
View File
@@ -74,11 +74,14 @@ class Seeders::AccountSeeder
def seed_contacts
@account_data['contacts'].each do |contact_data|
contact = @account.contacts.create!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
contact = @account.contacts.find_or_initialize_by(email: contact_data['email'])
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|
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)
end
end
+3 -2
View File
@@ -12,10 +12,11 @@ class Webhooks::Trigger
end
def execute
perform_request
response = perform_request
rescue StandardError => 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
private
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.2.0",
"version": "3.3.1",
"license": "MIT",
"scripts": {
"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
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
@@ -183,11 +183,11 @@ describe Integrations::Slack::SendOnSlackService do
it 'sent a template message on slack' do
builder = described_class.new(message: template_message, hook: hook)
allow(builder).to receive(:slack_client).and_return(slack_client)
template_message.update!(sender: nil)
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,
text: template_message.content,
username: "#{template_message.sender.name} (Contact)",
username: 'Bot',
thread_ts: conversation.identifier,
icon_url: anything,
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'
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
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,