Compare commits

...
Author SHA1 Message Date
Shivam Mishra 89e007c4c3 feat: disable upload after one message 2024-01-25 11:00:03 +05:30
Muhsin KelothandGitHub fa907840c7 feat: Add middle_name and last_name to contact model (#8771)
feat: Add `middle_name` and `last_name`
2024-01-24 16:22:04 +05:30
3760f206e8 fix: mutex timeout and error handling (#8770)
Fixes the follow cases 
- The ensure block released the lock even on LockAcquisitionError
- Custom timeout was not allowed

This also refactored the with_lock method, now the key has to be constructed in the parent function itself

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-24 14:18:21 +04:00
Sojan JoseandGitHub a861257f73 chore: Fix flaky contacts spec (#8773)
- The ordering was not guaranteed; hence, the specs were failing randomly. Made changes to the expectations accordingly
2024-01-24 13:58:27 +04:00
Muhsin KelothandGitHub 143299f138 feat: Add contact_type attribute to contact model (#8768) 2024-01-24 12:26:47 +05:30
Vishnu NarayananandGitHub 232369cd5c feat: sla 1 - refactor sla_policies model and add applied_sla model (#8602)
* feat: add models

* chore: refactor sla column names

* chore: remove foreign keys

* chore: fix spec

* chore: refactor models
2024-01-23 23:48:02 +05:30
Surabhi SumanandGitHub 4b40c61201 feat: Support Regex validation for custom attributes (#7856)
This allows a user to add/update a custom regex and a cue while defining custom attributes(Only applicable for type- text).
While adding/editing custom attributes, the values are validated against the attribute definition regex, and if it is incorrect, a cue message or default error message is shown and restricts invalid values from being saved.

Fixes: #6866
2024-01-23 18:01:57 +04:00
834c219b9b feat(perf): update query to do a simpler search [CW-2997] (#8763)
Message search would frequently timeout. The reason was that the query would join the conversation too, the new query searches the message table directly

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-23 14:20:00 +04:00
Sivin VargheseandGitHub d0cd1c8887 fix: Help center articles not accessible after authoring agent is deleted (#8756) 2024-01-23 14:06:51 +05:30
Muhsin KelothandGitHub 682a2aea1c chore: Handle twillio Down::ClientError (#8757)
Fixes: https://linear.app/chatwoot/issue/CW-2992/downclienterror-400-bad-request-downclienterror
2024-01-22 15:33:26 +04:00
Shivam MishraandGitHub 1dc66db516 fix: SQL error when rules with missing attributes is triggered (#8673) 2024-01-22 16:09:34 +05:30
Sojan JoseandGitHub bc04d81a5a fix: Handle Net::IMAP::InvalidResponseError Exception bad response type "ESMTP" (#8755) 2024-01-22 15:32:44 +05:30
d2c5c2f9a3 chore: [Snyk] Security upgrade sidekiq from 7.2.0 to 7.2.1 (#8748)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-01-22 13:12:26 +04:00
Sojan JoseandGitHub 381fda270a chore: Fix typo in Inbox Management copy (#8750)
- Fixes the typo in Inbox Management copy -> vistors to visitors
2024-01-22 13:07:36 +04:00
b3c9d1f1a5 fix: clear timers and animation frame request before component unmounts (#8700)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-22 12:08:33 +05:30
Sivin VargheseandGitHub fd4376d062 fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8753) 2024-01-22 12:06:59 +05:30
Pranav Raj SandGitHub a8f053921b fix: Migrate notes when merging the contacts (#8749)
Fixes: https://linear.app/chatwoot/issue/CW-2987/migrate-notes-of-the-secondary-contact-to-primary-contact-when-merging
2024-01-20 10:32:18 +04:00
Sivin VargheseandGitHub 8e15ada164 fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8747) 2024-01-19 19:55:51 +05:30
Sojan 51093fd543 Merge branch 'release/3.5.2' into develop 2024-01-19 15:15:33 +04:00
Sojan 48e638cc56 Bump version to 3.5.2 2024-01-19 15:14:49 +04:00
Pavel KuzminandGitHub e6dfc159ed fix: change teamid (#8613) 2024-01-19 16:15:28 +05:30
Chatwoot BotandGitHub 900c2de89a chore: Update translations (#8717) 2024-01-19 14:32:27 +04:00
29106ebc4b fix: raise_lock_acquisition_error if the job cannot set the lock (#8744)
Consider a scenario where two jobs are concurrently accessing a job with a lock mechanism. If the second job accesses lock_manager.locked? before the first job called lock_manager.lock(lock_key), it would return a false positive. At this point, both jobs can be executed freely.

To address this issue, the following change ensures that only the current thread that has set the lock can execute. Otherwise, a LockAcquisitionError will be thrown.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-01-19 14:31:41 +04:00
Pranav Raj SandGitHub a579684b34 fix: Update the message-id check to use messageId parsing from mail gem (#8743)
- Update the message ID parsing logic to use the mail gem
- Update the code to improve readability
2024-01-19 14:29:04 +04:00
c29a9ad062 feat: Updates branding logo and name in public portal footer (#8745)
- Use white-label settings for Chatwoot Help Center

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-19 13:37:24 +04:00
Muhsin KelothandGitHub 0ac015ce7a feat: Add a job to clear notifications that were created before 1 month (#8732) 2024-01-19 13:03:23 +05:30
19474e0074 chore: Improve active job error logs for deserialization error (#8742)
- Improve active job error logs for deserialization error 

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-18 19:27:18 +04:00
Sojan JoseandGitHub ce8190dacf fix: API error when using SuperAdmin token (#8739)
- Fixes the issue in release 3.5.0, which causes SuperAdmin tokens to throw error during API calls

Fixes: #8719
2024-01-18 18:49:32 +04:00
Shivam MishraandGitHub aacf326ca1 refactor: remove exception tracker (#8737) 2024-01-18 17:49:27 +05:30
Shivam MishraandGitHub e39c14460b fix: validate url for Dashboard Apps [CW-2979] (#8736) 2024-01-18 17:48:30 +05:30
Sojan JoseandGitHub 5f6e17f307 feat: Use Telegram HTML Parsemode (#8731)
- this ensures that the markdown formatted messages from the Chatwoot dashboard will render consistently in telegram UI for the supported types like bold, italics, links etc
2024-01-18 15:36:36 +04:00
Shivam MishraandGitHub 4bf23adcf5 feat: use short_summary for downloading reports [CW-2962] (#8733) 2024-01-18 16:06:13 +05:30
Pranav Raj SandGitHub 1f4d860d9d fix: Use channel.inbox instead of inbox (#8734) 2024-01-18 00:46:25 -08:00
fdbb3bf4b1 fix: Optimize email fetching logic to fix bandwidth exceeded issues (#8730)
The Inboxes::FetchImapEmailsJob is designed to fetch the entire email object and check if its message id is already in the database. However, this process is resource-intensive and time-consuming, as fetching the full email object takes a significant amount of time.

On average, fetching 100 emails can take approximately 3-4 minutes to complete depending on the IMAP server. Since we are not using server flags to identify which emails have already been fetched (to avoid compatibility issues with flags in different email services), we have to fetch all previously available emails. Currently we fetch all the messages that were created from yesterday. This becomes problematic with services like Gmail, which throttle requests based on bandwidth usage.

To address this issue, I have updated the logic as follows:

Fetch the sequence IDs of all the mails in the "Inbox" that were created from yesterday.
Use the FETCH command to fetch only the message-ids using BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] with the sequence IDs we got in the previous step. This is a faster operation with lower bandwidth usage, as it only returns the sequence ID and message ID.
Check if the message IDs are already present in the database for the selected inbox.
If not present, fetch the entire email object using the FETCH command and create the message.
If the message ID is already present, ignore it and move on to the next message-id.
I have also addressed the issue of duplicate emails appearing in conversations when two fetch email jobs occur simultaneously. I have added a lock for the channel to ensure that the job gracefully exits without waiting for the current job to complete.

Fixes #7247
Fixes #6082
Fixes #8314

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-18 11:45:16 +04:00
Muhsin KelothandGitHub c899cc825d fix: Handle Contact import MalformedCSVError (#8706) 2024-01-18 13:05:58 +05:30
Muhsin KelothandGitHub eb972684b3 feat: Show notification last active time instead of created time (#8724) 2024-01-18 09:41:53 +05:30
Muhsin KelothandGitHub 896473f03e fix: Notification count is incorrect when the number of notifications exceeds the page size. (#8723)
- The notification count is incorrect when the number of notifications exceeds the page size.
2024-01-17 15:14:37 +04:00
Sojan 8c43edbd50 Merge branch 'release/3.5.1' into develop 2024-01-17 13:48:28 +04:00
97 changed files with 1070 additions and 209 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
gem 'sidekiq', '>= 7.2.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
+5 -5
View File
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.2.2)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
crack (0.4.5)
rexml
@@ -610,7 +610,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.19.0)
redis-client (0.19.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -720,11 +720,11 @@ GEM
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.2.0)
sidekiq (7.2.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
redis-client (>= 0.19.0)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
@@ -939,7 +939,7 @@ DEPENDENCIES
sentry-ruby
sentry-sidekiq (>= 5.14.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq (>= 7.2.1)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
+5
View File
@@ -12,6 +12,7 @@ class ContactMergeAction
merge_conversations
merge_messages
merge_contact_inboxes
merge_contact_notes
merge_and_remove_mergee_contact
end
@base_contact
@@ -33,6 +34,10 @@ class ContactMergeAction
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end
def merge_contact_notes
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
end
def merge_messages
Message.where(sender: @mergee_contact).update(sender: @base_contact)
end
+8
View File
@@ -46,6 +46,14 @@ class V2::ReportBuilder
}
end
def short_summary
{
conversations_count: conversations.count,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary
}
end
def conversation_metrics
if params[:type].equal?(:account)
live_conversations
@@ -39,6 +39,8 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type,
:attribute_key,
:attribute_model,
:regex_pattern,
:regex_cue,
attribute_values: []
)
end
@@ -9,7 +9,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def index
@unread_count = notification_finder.unread_count
@notifications = notification_finder.perform
@count = @notifications.count
@count = notification_finder.count
end
def read_all
@@ -14,7 +14,14 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner
Current.user = @resource if [User, AgentBot].include?(@resource.class)
Current.user = @resource if allowed_current_user_type?(@resource)
end
def allowed_current_user_type?(resource)
return true if resource.is_a?(User)
return true if resource.is_a?(AgentBot)
false
end
def validate_bot_access_token!
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout
before_action :set_color_scheme
before_action :set_global_config
around_action :set_locale
after_action :allow_iframe_requests
@@ -60,4 +61,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
portal
render 'public/api/v1/portals/error/404', status: :not_found
end
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
end
end
+1 -1
View File
@@ -53,7 +53,7 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
ChatwootExceptionTracker.new(e).capture_exception if e.present?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def check_smtp_connection(channel_data, smtp)
@@ -37,7 +37,7 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
).summary
).short_summary
end
private
@@ -519,6 +519,9 @@ export default {
},
},
watch: {
teamId() {
this.updateVirtualListProps('teamId', this.teamId);
},
activeTeam() {
this.resetAndFetchData();
},
@@ -126,18 +126,26 @@ import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
},
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },
attributeType: { type: String, default: 'text' },
attributeRegex: {
type: String,
default: null,
},
regexCue: { type: String, default: null },
regexEnabled: { type: Boolean, default: false },
attributeKey: { type: String, required: true },
contactId: { type: Number, default: null },
},
@@ -204,6 +212,11 @@ export default {
if (this.$v.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (!this.$v.editedValue.regexValidation) {
return this.regexCue
? this.regexCue
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
},
@@ -221,7 +234,15 @@ export default {
};
}
return {
editedValue: { required },
editedValue: {
required,
regexValidation: value => {
return !(
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
);
},
},
};
},
mounted() {
@@ -56,7 +56,7 @@
class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1"
:class="
isCountZero
? `text-slate-300 dark:text-slate-700`
? `text-slate-300 dark:text-slate-500`
: `text-slate-700 dark:text-slate-50`
"
>
@@ -13,12 +13,17 @@
/>
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
v-tooltip.top-end="
disableFileUpload
? $t('CONVERSATION.REPLYBOX.TIP_ATTACHMENT_LIMIT_FB')
: $t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')
"
input-id="conversationAttachment"
:disabled="disableFileUpload"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="enableMultipleFileUpload"
:drop="enableDragAndDrop"
:drop="disableFileUpload ? false : enableDragAndDrop"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
@@ -29,7 +34,12 @@
<woot-button
v-if="showAttachButton"
class-names="button--upload"
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
:disabled="disableFileUpload"
:title="
disableFileUpload
? $t('CONVERSATION.REPLYBOX.TIP_ATTACHMENT_LIMIT_FB')
: $t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')
"
icon="attach"
emoji="📎"
color-scheme="secondary"
@@ -248,6 +258,10 @@ export default {
type: String,
required: true,
},
disableFileUpload: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -109,6 +109,7 @@
<reply-bottom-panel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:disable-file-upload="disableFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
@@ -465,6 +466,13 @@ export default {
showReplyHead() {
return !this.isOnPrivateNote && this.isAnEmailChannel;
},
disableFileUpload() {
if (this.isAFacebookInbox) {
return this.hasAttachments > 0;
}
return false;
},
enableMultipleFileUpload() {
return (
this.isAnEmailChannel ||
@@ -5,7 +5,7 @@
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click="toggleCountryDropdown"
>
<h5 v-if="activeCountry.emoji" class="mb-0">
<h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }}
</h5>
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
@@ -47,6 +47,8 @@ export const getCustomFields = ({ standardFields, customAttributes }) => {
type: attribute.attribute_display_type,
values: attribute.attribute_values,
field_type: attribute.attribute_model,
regex_pattern: attribute.regex_pattern,
regex_cue: attribute.regex_cue,
required: false,
enabled: false,
});
@@ -44,4 +44,18 @@ export default {
created_at: '2021-11-29T10:20:04.563Z',
},
],
customAttributesWithRegex: [
{
id: 2,
attribute_description: 'Test contact Attribute',
attribute_display_name: 'Test contact Attribute',
attribute_display_type: 'text',
attribute_key: 'test_contact_attribute',
attribute_model: 'contact_attribute',
attribute_values: Array(0),
created_at: '2023-09-20T10:20:04.563Z',
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
],
};
@@ -5,7 +5,8 @@ import {
} from '../preChat';
import inboxFixture from './inboxFixture';
const { customFields, customAttributes } = inboxFixture;
const { customFields, customAttributes, customAttributesWithRegex } =
inboxFixture;
describe('#Pre chat Helpers', () => {
describe('getPreChatFields', () => {
it('should return correct pre-chat fields form options passed', () => {
@@ -27,7 +28,6 @@ describe('#Pre chat Helpers', () => {
placeholder: 'Please enter your email address',
type: 'email',
field_type: 'standard',
required: false,
enabled: false,
},
@@ -71,6 +71,26 @@ describe('#Pre chat Helpers', () => {
values: [],
},
]);
expect(
getCustomFields({
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
customAttributes: customAttributesWithRegex,
})
).toEqual([
{
enabled: false,
label: 'Test contact Attribute',
placeholder: 'Test contact Attribute',
name: 'test_contact_attribute',
required: false,
field_type: 'contact_attribute',
type: 'text',
values: [],
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
]);
});
});
});
@@ -39,6 +39,17 @@
"PLACEHOLDER": "Enter custom attribute key",
"ERROR": "Key is required",
"IN_VALID": "Invalid key"
},
"REGEX_PATTERN": {
"LABEL": "Regex Pattern",
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
},
"REGEX_CUE": {
"LABEL": "Regex Cue",
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
}
},
"API": {
@@ -88,6 +99,17 @@
"EMPTY_RESULT": {
"404": "There are no custom attributes created",
"NOT_FOUND": "There are no custom attributes configured"
},
"REGEX_PATTERN": {
"LABEL": "Regex Pattern",
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
},
"REGEX_CUE": {
"LABEL": "Regex Cue",
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
}
}
}
@@ -339,7 +339,8 @@
},
"VALIDATIONS": {
"REQUIRED": "Valid value is required",
"INVALID_URL": "Invalid URL"
"INVALID_URL": "Invalid URL",
"INVALID_INPUT": "Invalid Input"
}
},
"MERGE_CONTACTS": {
@@ -148,6 +148,7 @@
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_ATTACHMENT_LIMIT_FB": "You cannot attach more files to this message",
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
@@ -323,4 +324,4 @@
"TRANSLATED_CONTENT": "Translated Content",
"NO_TRANSLATIONS_AVAILABLE": "No translations are available for this content"
}
}
}
@@ -324,7 +324,8 @@
"LAST_EDITED": "Last edited"
},
"COLUMNS": {
"BY": "by"
"BY": "by",
"AUTHOR_NOT_AVAILABLE": "Author is not available"
}
},
"EDIT_ARTICLE": {
@@ -569,7 +569,7 @@
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
"DAY": {
"ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable",
@@ -126,6 +126,6 @@
"NO_CONTENT": "Содержимое отсутствует",
"HIDE_QUOTED_TEXT": "Скрыть цитируемый текст",
"SHOW_QUOTED_TEXT": "Показать цитируемый текст",
"MESSAGE_READ": "Читать"
"MESSAGE_READ": "Прочитано"
}
}
@@ -0,0 +1,11 @@
export default {
methods: {
getRegexp(regexPatternValue) {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
},
},
};
@@ -11,6 +11,8 @@
emoji=""
:value="attribute.value"
:show-actions="true"
:attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue"
:class="attributeClass"
@update="onUpdate"
@delete="onDelete"
@@ -11,9 +11,30 @@
{{ title }}
</h6>
</router-link>
<div class="author">
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
<span class="name">{{ articleAuthorName }}</span>
<div class="flex gap-1 items-center">
<Thumbnail
v-if="author"
:src="author.thumbnail"
:username="author.name"
size="16px"
/>
<div
v-else
v-tooltip.right="
$t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE')
"
class="flex items-center justify-center rounded w-4 h-4 bg-woot-100 dark:bg-woot-700"
>
<fluent-icon
icon="person"
type="filled"
size="10"
class="text-woot-300 dark:text-woot-300"
/>
</div>
<span class="font-normal text-slate-500 dark:text-slate-200 text-sm">
{{ articleAuthorName }}
</span>
</div>
</div>
</span>
@@ -57,10 +78,12 @@ import timeMixin from 'dashboard/mixins/time';
import portalMixin from '../mixins/portalMixin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
EmojiOrIcon,
Thumbnail,
},
mixins: [timeMixin, portalMixin],
props: {
@@ -110,7 +133,7 @@ export default {
}).format(this.views || 0);
},
articleAuthorName() {
return this.author.name;
return this.author?.name || '-';
},
labelColor() {
switch (this.status) {
@@ -189,15 +212,6 @@ export default {
.article-block {
@apply min-w-0;
}
.author {
.by {
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
}
.name {
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
}
}
}
span {
@@ -56,7 +56,7 @@
<span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
>
{{ dynamicTime(notificationItem.created_at) }}
{{ dynamicTime(notificationItem.last_activity_at) }}
</span>
</div>
</div>
@@ -58,7 +58,7 @@
<td>
<div class="text-right timestamp--column">
<span class="notification--created-at">
{{ dynamicTime(notificationItem.created_at) }}
{{ dynamicTime(notificationItem.last_activity_at) }}
</span>
</div>
</td>
@@ -11,6 +11,7 @@
<table-footer
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@page-change="onPageChange"
/>
</div>
@@ -86,6 +86,30 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
</div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-submit-button
:disabled="isButtonDisabled"
@@ -124,6 +148,9 @@ export default {
attributeModel: 0,
attributeType: 0,
attributeKey: '',
regexPattern: null,
regexCue: null,
regexEnabled: false,
models: ATTRIBUTE_MODELS,
types: ATTRIBUTE_TYPES,
values: [],
@@ -163,6 +190,12 @@ export default {
isAttributeTypeList() {
return this.attributeType === 6;
},
isAttributeTypeText() {
return this.attributeType === 0;
},
isRegexEnabled() {
return this.regexEnabled;
},
},
validations: {
@@ -201,11 +234,18 @@ export default {
onDisplayNameChange() {
this.attributeKey = convertToAttributeSlug(this.displayName);
},
toggleRegexEnabled() {
this.regexEnabled = !this.regexEnabled;
},
async addAttributes() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
if (!this.regexEnabled) {
this.regexPattern = null;
this.regexCue = null;
}
try {
await this.$store.dispatch('attributes/create', {
attribute_display_name: this.displayName,
@@ -214,6 +254,10 @@ export default {
attribute_display_type: this.attributeType,
attribute_key: this.attributeKey,
attribute_values: this.attributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
this.onClose();
@@ -70,6 +70,30 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
</div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
</div>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-button :is-loading="isUpdating" :disabled="isButtonDisabled">
@@ -88,9 +112,10 @@ import { mapGetters } from 'vuex';
import { required, minLength } from 'vuelidate/lib/validators';
import { ATTRIBUTE_TYPES } from './constants';
import alertMixin from 'shared/mixins/alertMixin';
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
export default {
components: {},
mixins: [alertMixin],
mixins: [alertMixin, customAttributeMixin],
props: {
selectedAttribute: {
type: Object,
@@ -106,6 +131,9 @@ export default {
displayName: '',
description: '',
attributeType: 0,
regexPattern: null,
regexCue: null,
regexEnabled: false,
types: ATTRIBUTE_TYPES,
show: true,
attributeKey: '',
@@ -152,6 +180,7 @@ export default {
this.isAttributeTypeList && this.isTouched && this.values.length === 0
);
},
pageTitle() {
return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${
this.selectedAttribute.attribute_display_name
@@ -173,6 +202,12 @@ export default {
isAttributeTypeList() {
return this.attributeType === 6;
},
isAttributeTypeText() {
return this.attributeType === 0;
},
isRegexEnabled() {
return this.regexEnabled;
},
},
mounted() {
this.setFormValues();
@@ -189,10 +224,16 @@ export default {
this.$refs.tagInput.$el.focus();
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? this.getRegexp(this.selectedAttribute.regex_pattern).source
: null;
this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description;
this.attributeType = this.selectedAttributeType;
this.attributeKey = this.selectedAttribute.attribute_key;
this.regexPattern = regexPattern;
this.regexCue = this.selectedAttribute.regex_cue;
this.regexEnabled = regexPattern != null;
this.values = this.setAttributeListValue;
},
async editAttributes() {
@@ -200,14 +241,21 @@ export default {
if (this.$v.$invalid) {
return;
}
if (!this.regexEnabled) {
this.regexPattern = null;
this.regexCue = null;
}
try {
await this.$store.dispatch('attributes/update', {
id: this.selectedAttribute.id,
attribute_description: this.description,
attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
this.onClose();
} catch (error) {
@@ -218,6 +266,9 @@ export default {
this.showAlert(this.alertMessage);
}
},
toggleRegexEnabled() {
this.regexEnabled = !this.regexEnabled;
},
},
};
</script>
@@ -84,6 +84,10 @@ export default {
},
},
beforeDestroy() {
clearTimeout(this.typingTimer);
},
methods: {
onChange(e) {
this.$emit('input', e.target.value);
@@ -154,6 +154,7 @@
"person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z",
"person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z",
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
@@ -28,6 +28,9 @@
isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'),
email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'),
required: $t('PRE_CHAT_FORM.REQUIRED'),
matches: item.regex_cue
? item.regex_cue
: $t('PRE_CHAT_FORM.REGEX_ERROR'),
}"
:has-error-in-phone-input="hasErrorInPhoneInput"
/>
@@ -68,13 +71,20 @@ import { isEmptyObject } from 'widget/helpers/utils';
import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin';
export default {
components: {
CustomButton,
Spinner,
},
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin],
mixins: [
routerMixin,
darkModeMixin,
messageFormatterMixin,
configMixin,
customAttributeMixin,
],
props: {
options: {
type: Object,
@@ -235,30 +245,37 @@ export default {
}
return this.formValues[name] || null;
},
getValidation({ type, name }) {
getValidation({ type, name, field_type, regex_pattern }) {
let regex = regex_pattern ? this.getRegexp(regex_pattern) : null;
const validations = {
emailAddress: 'email',
phoneNumber: 'startsWithPlus|isValidPhoneNumber',
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
url: 'url',
date: 'date',
text: null,
select: null,
number: null,
checkbox: false,
contact_attribute: regex ? [['matches', regex]] : null,
conversation_attribute: regex ? [['matches', regex]] : null,
};
const validationKeys = Object.keys(validations);
const isRequired = this.isContactFieldRequired(name);
const validation = isRequired ? 'bail|required' : 'bail|optional';
const validation = isRequired
? ['bail', 'required']
: ['bail', 'optional'];
if (validationKeys.includes(name) || validationKeys.includes(type)) {
const validationType = validations[type] || validations[name];
const validationString = validationType
? `${validation}|${validationType}`
: validation;
return validationString;
if (
validationKeys.includes(name) ||
validationKeys.includes(type) ||
validationKeys.includes(field_type)
) {
const validationType =
validations[type] || validations[name] || validations[field_type];
return validationType ? validation.concat(validationType) : validation;
}
return '';
return [];
},
findFieldType(type) {
if (type === 'link') {
@@ -24,6 +24,11 @@ export default {
default: () => {},
},
},
data() {
return {
timeOutID: null,
};
},
computed: {
replyToAttachment() {
if (!this.replyTo?.attachments?.length) {
@@ -34,6 +39,10 @@ export default {
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
},
},
beforeDestroy() {
clearTimeout(this.timeOutID);
},
methods: {
navigateTo(id) {
const elementId = `cwmsg-${id}`;
@@ -41,7 +50,7 @@ export default {
const el = document.getElementById(elementId);
el.scrollIntoView();
el.classList.add('bg-slate-100', 'dark:bg-slate-900');
setTimeout(() => {
this.timeOutID = setTimeout(() => {
el.classList.remove('bg-slate-100', 'dark:bg-slate-900');
}, 500);
});
@@ -60,6 +60,7 @@ export default {
scrollPosition: 0,
ticking: true,
disableBranding: window.chatwootWebChannel.disableBranding || false,
requestID: null,
};
},
computed: {
@@ -120,6 +121,7 @@ export default {
},
beforeDestroy() {
this.$el.removeEventListener('scroll', this.updateScrollPosition);
cancelAnimationFrame(this.requestID);
},
methods: {
closeWindow() {
@@ -128,7 +130,7 @@ export default {
updateScrollPosition(event) {
this.scrollPosition = event.target.scrollTop;
if (!this.ticking) {
window.requestAnimationFrame(() => {
this.requestID = window.requestAnimationFrame(() => {
this.ticking = false;
});
+2 -1
View File
@@ -80,7 +80,8 @@
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required"
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"CHAT_FORM": {
+4 -2
View File
@@ -1,6 +1,8 @@
class ApplicationJob < ActiveJob::Base
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
discard_on ActiveJob::DeserializationError do |_job, error|
Rails.logger.error("Skipping job because of ActiveJob::DeserializationError (#{error.message})")
discard_on ActiveJob::DeserializationError do |job, error|
Rails.logger.info("Skipping #{job.class} with #{
job.instance_variable_get(:@serialized_arguments)
} because of ActiveJob::DeserializationError (#{error.message})")
end
end
+15 -2
View File
@@ -8,8 +8,12 @@ class DataImportJob < ApplicationJob
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
process_import_file
send_import_notification_to_admin
begin
process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end
private
@@ -83,7 +87,16 @@ class DataImportJob < ApplicationJob
end
end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
def send_import_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def send_import_failed_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
end
+81 -48
View File
@@ -1,17 +1,22 @@
require 'net/imap'
class Inboxes::FetchImapEmailsJob < ApplicationJob
class Inboxes::FetchImapEmailsJob < MutexApplicationJob
queue_as :scheduled_jobs
def perform(channel)
return unless should_fetch_email?(channel)
process_email_for_channel(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e
channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
Rails.logger.error e
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
@@ -23,7 +28,6 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end
def process_email_for_channel(channel)
# fetching email for microsoft provider
if channel.microsoft?
fetch_mail_for_ms_provider(channel)
else
@@ -34,32 +38,66 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end
def fetch_mail_for_channel(channel)
imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN')
last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel)))
imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id)
next if email_already_present?(channel, inbound_mail, last_email_time)
process_mail(inbound_mail, channel)
message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
message_ids_with_seq.each do |message_id_with_seq|
process_message_id(channel, imap_client, message_id_with_seq)
end
end
def email_already_present?(channel, inbound_mail, _last_email_time)
channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
def process_message_id(channel, imap_client, message_id_with_seq)
seq_no, message_id = message_id_with_seq
return if email_already_present?(channel, message_id)
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
return
end
inbound_mail = build_mail_from_string(mail_str)
mail_info_logger(channel, inbound_mail, seq_no)
process_mail(inbound_mail, channel)
end
def received_mails(imap_inbox)
imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday])
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
# You can send batches of message sequence number in `.fetch` method.
def fetch_message_ids_with_sequence(imap_client, channel)
seq_nums = fetch_available_mail_sequence_numbers(imap_client)
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
message_ids_with_seq
end
def processed_email?(current_email, last_email_time)
return current_email.date < last_email_time if current_email.date.present?
false
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday and today and returns message sequence numbers.
# Return <message set>
def fetch_available_mail_sequence_numbers(imap_client)
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
end
def fetch_mail_for_ms_provider(channel)
@@ -69,16 +107,15 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless access_token
imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2')
process_mails(imap_inbox, channel)
imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
process_mails(imap_client, channel)
end
def process_mails(imap_inbox, channel)
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
def process_mails(imap_client, channel)
fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id)
mail_info_logger(channel, inbound_mail, seq_no)
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
@@ -86,38 +123,26 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end
end
def mail_info_logger(channel, inbound_mail, message_id)
def mail_info_logger(channel, inbound_mail, uid)
return if Rails.env.test?
Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}")
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
end
def authenticated_imap_inbox(channel, access_token, auth_method)
def build_imap_client(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token)
imap.select('INBOX')
imap
end
def last_email_time(channel)
# we are only checking for emails in last 2 day
last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last
if last_email_incoming_message.present?
time = last_email_incoming_message.content_attributes['email']['date']
time ||= last_email_incoming_message.created_at.to_s
end
time ||= 1.hour.ago.to_s
DateTime.parse(time)
def email_already_present?(channel, message_id)
channel.inbox.messages.find_by(source_id: message_id).present?
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
def build_mail_from_string(raw_email_content)
Mail.read_from_string(raw_email_content)
end
def process_mail(inbound_mail, channel)
@@ -132,4 +157,12 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end
end
@@ -12,7 +12,9 @@ class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob
pre_chat_field.deep_merge({
'label' => custom_attribute['attribute_display_name'],
'placeholder' => custom_attribute['attribute_display_name'],
'values' => custom_attribute['attribute_values']
'values' => custom_attribute['attribute_values'],
'regex_pattern' => custom_attribute['regex_pattern'],
'regex_cue' => custom_attribute['regex_cue']
})
end
web_widget.save!
+27 -13
View File
@@ -14,22 +14,36 @@
class MutexApplicationJob < ApplicationJob
class LockAcquisitionError < StandardError; end
def with_lock(key_format, *args)
lock_key = format(key_format, *args)
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
lock_manager = Redis::LockManager.new
if lock_manager.locked?(lock_key)
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
begin
lock_manager.lock(lock_key)
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
yield
ensure
# Ensure that the lock is released even if there's an error in processing
lock_manager.unlock(lock_key)
if lock_manager.lock(lock_key, timeout)
log_attempt(lock_key, executions)
yield
# release the lock after the block has been executed
lock_manager.unlock(lock_key)
else
handle_failed_lock_acquisition(lock_key)
end
rescue StandardError => e
handle_error(e, lock_manager, lock_key)
end
end
private
def log_attempt(lock_key, executions)
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
end
def handle_error(err, lock_manager, lock_key)
lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError)
raise err
end
def handle_failed_lock_acquisition(lock_key)
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
end
@@ -0,0 +1,8 @@
class Notification::RemoveOldNotificationJob < ApplicationJob
queue_as :low
def perform
Notification.where('created_at < ?', 1.month.ago)
.find_each(batch_size: 1000, &:delete)
end
end
+2 -1
View File
@@ -3,7 +3,8 @@ class SendOnSlackJob < MutexApplicationJob
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
end
+3
View File
@@ -19,5 +19,8 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to clear notifications which are older than 1 month
Notification::RemoveOldNotificationJob.perform_later
end
end
+2 -1
View File
@@ -5,7 +5,8 @@ class Webhooks::FacebookEventsJob < MutexApplicationJob
def perform(message)
response = ::Integrations::Facebook::MessageParser.new(message)
with_lock(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) do
key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
with_lock(key) do
process_message(response)
end
end
+2 -1
View File
@@ -12,7 +12,8 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
def perform(entries)
@entries = entries
with_lock(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) do
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
with_lock(key) do
process_entries(entries)
end
end
@@ -51,6 +51,15 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_import_failed
return unless smtp_config_set_or_development?
subject = 'Contact Import Failed'
@meta = {}
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url)
return unless smtp_config_set_or_development?
+18 -10
View File
@@ -149,24 +149,32 @@ class Channel::Telegram < ApplicationRecord
})
end
def convert_markdown_to_telegram(text)
## supported characters : https://core.telegram.org/bots/api#markdown-style
## To implement MarkdownV2, we will need to do a lot of escaping
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# Convert bold - double asterisks to single asterisk in Telegram
# Chatwoot uses double asterisks for bold, while telegram used single asterisk
text.gsub!(/\*\*(.*?)\*\*/, '*\1*')
text
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
# convert markdown to html
html = CommonMarker.render_html(text).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
text_to_md = convert_markdown_to_telegram(text)
text_payload = convert_markdown_to_telegram_html(text)
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text_to_md,
text: text_payload,
reply_markup: reply_markup,
parse_mode: 'Markdown',
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
})
end
+1 -1
View File
@@ -35,7 +35,7 @@ class Channel::WebWidget < ApplicationRecord
{ pre_chat_form_options: [:pre_chat_message, :require_email,
{ pre_chat_fields:
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
:locale, { values: [] }] }] },
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
{ selected_feature_flags: [] }].freeze
before_validation :validate_pre_chat_options
+5
View File
@@ -6,10 +6,13 @@
#
# id :integer not null, primary key
# additional_attributes :jsonb
# contact_type :integer default("visitor")
# custom_attributes :jsonb
# email :string
# identifier :string
# last_activity_at :datetime
# last_name :string default("")
# middle_name :string default("")
# name :string default("")
# phone_number :string
# created_at :datetime not null
@@ -55,6 +58,8 @@ class Contact < ApplicationRecord
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
scope :order_on_last_activity_at, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
@@ -10,6 +10,8 @@
# attribute_model :integer default("conversation_attribute")
# attribute_values :jsonb
# default_value :integer
# regex_cue :string
# regex_pattern :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
+4 -1
View File
@@ -33,9 +33,12 @@ class DashboardApp < ApplicationRecord
'required' => %w[url type],
'properties' => {
'type' => { 'enum': ['frame'] },
'url' => { :type => 'string', 'format' => 'uri' }
'url' => { '$ref' => '#/definitions/saneUrl' }
}
},
'definitions' => {
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
},
'additionalProperties' => false,
'minItems' => 1
}
+1 -1
View File
@@ -66,7 +66,7 @@ class Inbox < ApplicationRecord
has_many :inbox_members, dependent: :destroy_async
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async
has_many :messages, through: :conversations
has_many :messages, dependent: :destroy_async
has_one :agent_bot_inbox, dependent: :destroy_async
has_one :agent_bot, through: :agent_bot_inbox
@@ -0,0 +1,56 @@
class AutomationRules::ConditionValidationService
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def initialize(rule)
@rule = rule
@account = rule.account
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
end
def perform
@rule.conditions.each do |condition|
return false unless valid_condition?(condition)
end
true
end
private
def valid_condition?(condition)
key = condition['attribute_key']
conversation_filter = @conversation_filters[key]
contact_filter = @contact_filters[key]
message_filter = @message_filters[key]
if conversation_filter || contact_filter || message_filter
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
else
custom_attribute_present?(key, condition['custom_attribute_type'])
end
end
def operation_valid?(condition, filter)
filter_operator = condition['filter_operator']
# attribute changed is a special case
return true if filter_operator == 'attribute_changed'
filter['filter_operators'].include?(filter_operator)
end
def custom_attribute_present?(attribute_key, attribute_model)
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
@account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key).present?
end
end
@@ -5,19 +5,25 @@ class AutomationRules::ConditionsFilterService < FilterService
def initialize(rule, conversation = nil, options = {})
super([], nil)
# assign rule, conversation and account to instance variables
@rule = rule
@conversation = conversation
@account = conversation.account
# setup filters from json file
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
@options = options
@changed_attributes = options[:changed_attributes]
end
def perform
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
return false unless rule_valid?
@attribute_changed_query_filter = []
@rule.conditions.each_with_index do |query_hash, current_index|
@@ -36,6 +42,14 @@ class AutomationRules::ConditionsFilterService < FilterService
false
end
def rule_valid?
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
is_valid
end
def filter_operation(query_hash, current_index)
if query_hash[:filter_operator] == 'starts_with'
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
@@ -127,7 +127,7 @@ class Twilio::IncomingMessageService
def download_attachment_file
download_with_auth
rescue Down::Error => e
rescue Down::Error, Down::ClientError => e
handle_download_attachment_error(e)
end
@@ -141,12 +141,10 @@ class Twilio::IncomingMessageService
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
def handle_download_attachment_error(error)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}"
if error.message.include?('401 Unauthorized')
Down.download(params[:MediaUrl0])
else
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
nil
end
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
Down.download(params[:MediaUrl0])
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
nil
end
end
@@ -19,6 +19,7 @@ json.data do
json.secondary_actor notification.secondary_actor&.push_event_data
json.user notification.user.push_event_data
json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
end
end
end
@@ -3,6 +3,8 @@ json.attribute_display_name resource.attribute_display_name
json.attribute_display_type resource.attribute_display_type
json.attribute_description resource.attribute_description
json.attribute_key resource.attribute_key
json.regex_pattern resource.regex_pattern
json.regex_cue resource.regex_cue
json.attribute_values resource.attribute_values
json.attribute_model resource.attribute_model
json.default_value resource.default_value
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>woot-log</title>
<desc>Created with Sketch.</desc>
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="woot-log" fill-rule="nonzero">
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,3 @@
<p>Hello,</p>
<p>Your contact import has failed. It appears that the CSV file you uploaded may not be valid. We kindly request that you review the file and ensure it complies with the required format.<p/>
@@ -2,9 +2,17 @@
<footer class="pt-16 pb-8 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl text-center py-2">
<div class="flex items-center gap-2">
<%= render partial: 'icons/chatwoot-logo' %>
<img
class="w-4 h-4"
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %> <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a>
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p>
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.5.1'
version: '3.5.2'
development:
<<: *shared
@@ -0,0 +1,6 @@
class AddRegexToCustomAttributeDefinition < ActiveRecord::Migration[7.0]
def change
add_column :custom_attribute_definitions, :regex_pattern, :string
add_column :custom_attribute_definitions, :regex_cue, :string
end
end
@@ -0,0 +1,8 @@
class RefactorSlaPolicyColumns < ActiveRecord::Migration[7.0]
def change
rename_column :sla_policies, :rt_threshold, :next_response_time_threshold
rename_column :sla_policies, :frt_threshold, :first_response_time_threshold
add_column :sla_policies, :description, :string
add_column :sla_policies, :resolution_time_threshold, :float
end
end
@@ -0,0 +1,13 @@
class CreateAppliedSlas < ActiveRecord::Migration[7.0]
def change
create_table :applied_slas do |t|
t.references :account, null: false
t.references :sla_policy, null: false
t.references :conversation, null: false
t.string :sla_status
t.timestamps
end
end
end
@@ -0,0 +1,5 @@
class AddContactTypeToContacts < ActiveRecord::Migration[7.0]
def change
add_column :contacts, :contact_type, :integer, default: 0
end
end
@@ -0,0 +1,6 @@
class AddMiddleNameAndLastNameToContacts < ActiveRecord::Migration[7.0]
def change
add_column :contacts, :middle_name, :string, default: ''
add_column :contacts, :last_name, :string, default: ''
end
end
+22 -3
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_12_19_073832) do
ActiveRecord::Schema[7.0].define(version: 2024_01_24_084032) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -115,6 +115,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.index ["account_id"], name: "index_agent_bots_on_account_id"
end
create_table "applied_slas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
t.bigint "conversation_id", null: false
t.string "sla_status"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_applied_slas_on_account_id"
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
end
create_table "articles", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "portal_id", null: false
@@ -406,6 +418,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.string "identifier"
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.integer "contact_type", default: 0
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id"], name: "index_contacts_on_account_id"
@@ -501,6 +516,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.datetime "updated_at", null: false
t.text "attribute_description"
t.jsonb "attribute_values", default: []
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end
@@ -822,12 +839,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
create_table "sla_policies", force: :cascade do |t|
t.string "name", null: false
t.float "frt_threshold"
t.float "rt_threshold"
t.float "first_response_time_threshold"
t.float "next_response_time_threshold"
t.boolean "only_during_business_hours", default: false
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "description"
t.float "resolution_time_threshold"
t.index ["account_id"], name: "index_sla_policies_on_account_id"
end
@@ -22,7 +22,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
end
def permitted_params
params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours)
params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
:resolution_time_threshold, :only_during_business_hours)
end
def fetch_sla
+23
View File
@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: applied_slas
#
# id :bigint not null, primary key
# sla_status :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# sla_policy_id :bigint not null
#
# Indexes
#
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
#
class AppliedSla < ApplicationRecord
belongs_to :account
belongs_to :sla_policy
belongs_to :conversation
end
+10 -8
View File
@@ -2,14 +2,16 @@
#
# Table name: sla_policies
#
# id :bigint not null, primary key
# frt_threshold :float
# name :string not null
# only_during_business_hours :boolean default(FALSE)
# rt_threshold :float
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# description :string
# first_response_time_threshold :float
# name :string not null
# next_response_time_threshold :float
# only_during_business_hours :boolean default(FALSE)
# resolution_time_threshold :float
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -1,5 +1,7 @@
json.id sla_policy.id
json.name sla_policy.name
json.frt_threshold sla_policy.frt_threshold
json.rt_threshold sla_policy.rt_threshold
json.description sla_policy.description
json.first_response_time_threshold sla_policy.first_response_time_threshold
json.next_response_time_threshold sla_policy.next_response_time_threshold
json.resolution_time_threshold sla_policy.resolution_time_threshold
json.only_during_business_hours sla_policy.only_during_business_hours
+1 -1
View File
@@ -118,7 +118,7 @@
"attribute_name": "Phone Number",
"input_type": "text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"],
"attribute_type": "standard"
},
"email": {
+1
View File
@@ -38,4 +38,5 @@ module Redis::RedisKeys
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.5.1",
"version": "3.5.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
+12
View File
@@ -18,6 +18,7 @@ describe ContactMergeAction do
create(:conversation, contact: base_contact)
create(:conversation, contact: mergee_contact)
create(:message, sender: mergee_contact)
create(:note, contact: mergee_contact, account: mergee_contact.account)
end
end
@@ -68,6 +69,17 @@ describe ContactMergeAction do
end
end
context 'when mergee contact has notes' do
it 'moves the notes to base contact' do
expect(base_contact.notes.count).to be 0
expect(mergee_contact.notes.count).to be 2
contact_merge
expect(base_contact.reload.notes.count).to be 2
end
end
context 'when contacts belong to a different account' do
it 'throws an exception' do
new_account = create(:account)
@@ -29,6 +29,25 @@ RSpec.describe 'API Base', type: :request do
end
end
describe 'request with api_access_token for a super admin' do
before do
user.update!(type: 'SuperAdmin')
end
context 'when its a valid api_access_token' do
it 'returns current user information' do
get '/api/v1/profile',
headers: { api_access_token: user.access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(user.id)
expect(json_response['email']).to eq(user.email)
end
end
end
describe 'request with api_access_token for bot' do
let!(:agent_bot) { create(:agent_bot) }
let!(:inbox) { create(:inbox, account: account) }
@@ -36,9 +36,11 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact.email)
expect(response_body['payload'].first['contact_inboxes'].first['source_id']).to eq(contact_inbox.source_id)
expect(response_body['payload'].first['contact_inboxes'].first['inbox']['name']).to eq(contact_inbox.inbox.name)
contact_emails = response_body['payload'].pluck('email')
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
expect(contact_emails).to include(contact.email)
expect(contact_inboxes_source_ids).to include(contact_inbox.source_id)
end
it 'returns all contacts without contact inboxes' do
@@ -55,8 +55,12 @@ RSpec.describe 'DashboardAppsController', type: :request do
describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
let(:no_ssl_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'http://link.com' }] } } }
let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } }
let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } }
let(:non_http_url_payload) do
{ dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'ftp://wontwork.chatwoot.com/hello-world' }] } }
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -82,6 +86,19 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end
it 'creates the dashboard app even if the URL does not have SSL' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
params: no_ssl_payload
end.to change(DashboardApp, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['title']).to eq 'CRM Dashboard'
expect(json_response['content'][0]['link']).to eq payload[:dashboard_app][:content][0][:link]
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end
it 'does not create the dashboard app if invalid URL' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
@@ -93,6 +110,17 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['message']).to eq 'Content : Invalid data'
end
it 'does not create the dashboard app if non HTTP URL' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
params: non_http_url_payload
end.not_to change(DashboardApp, :count)
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eq 'Content : Invalid data'
end
it 'does not create the dashboard app if invalid type' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
@@ -80,8 +80,10 @@ RSpec.describe 'Enterprise SLA API', type: :request do
describe 'POST #create' do
let(:valid_params) do
{ sla_policy: { name: 'SLA 2',
frt_threshold: 1000,
rt_threshold: 1000,
description: 'SLA for premium customers',
first_response_time_threshold: 1000,
next_response_time_threshold: 2000,
resolution_time_threshold: 3000,
only_during_business_hours: false } }
end
@@ -0,0 +1,16 @@
require 'rails_helper'
RSpec.describe AppliedSla, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:sla_policy) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
end
describe 'validates_factory' do
it 'creates valid applied sla policy object' do
applied_sla = create(:applied_sla)
expect(applied_sla.sla_status).to eq 'active'
end
end
end
@@ -18,6 +18,11 @@ RSpec.describe SlaPolicy, type: :model do
it 'creates valid sla policy object' do
sla_policy = create(:sla_policy)
expect(sla_policy.name).to eq 'sla_1'
expect(sla_policy.first_response_time_threshold).to eq 2000
expect(sla_policy.description).to eq 'SLA policy for enterprise customers'
expect(sla_policy.next_response_time_threshold).to eq 1000
expect(sla_policy.resolution_time_threshold).to eq 3000
expect(sla_policy.only_during_business_hours).to be false
end
end
end
+8
View File
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :applied_sla do
account
sla_policy
conversation
sla_status { 'active' }
end
end
+4 -2
View File
@@ -2,8 +2,10 @@ FactoryBot.define do
factory :sla_policy do
account
name { 'sla_1' }
rt_threshold { 1000 }
frt_threshold { 2000 }
first_response_time_threshold { 2000 }
description { 'SLA policy for enterprise customers' }
next_response_time_threshold { 1000 }
resolution_time_threshold { 3000 }
only_during_business_hours { false }
end
end
+15
View File
@@ -151,5 +151,20 @@ RSpec.describe DataImportJob do
end
end
end
context 'when the CSV file is invalid' do
let(:invalid_csv_content) do
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
end
before do
allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content)
end
it 'does not import any data and handles the MalformedCSVError' do
expect { described_class.perform_now(data_import) }
.to change { data_import.reload.status }.from('pending').to('failed')
end
end
end
end
@@ -11,7 +11,9 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do
pre_chat_message = 'Share your queries here.'
custom_attribute = {
'attribute_key' => 'developer_id',
'attribute_display_name' => 'Developer Number'
'attribute_display_name' => 'Developer Number',
'regex_pattern' => '^[0-9]*',
'regex_cue' => 'It should be only digits'
}
let!(:account) { create(:account) }
let!(:web_widget) do
@@ -23,7 +25,8 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do
described_class.perform_now(account, custom_attribute)
expect(web_widget.reload.pre_chat_form_options['pre_chat_fields']).to eq [
{ 'label' => 'Developer Number', 'name' => 'developer_id', 'placeholder' => 'Developer Number',
'values' => nil }, { 'label' => 'Full Name', 'name' => 'full_name' }
'values' => nil, 'regex_pattern' => '^[0-9]*', 'regex_cue' => 'It should be only digits' },
{ 'label' => 'Full Name', 'name' => 'full_name' }
]
end
end
+22 -16
View File
@@ -4,45 +4,51 @@ RSpec.describe MutexApplicationJob do
let(:lock_manager) { instance_double(Redis::LockManager) }
let(:lock_key) { 'test_key' }
let(:test_mutex_job_class) do
stub_const('TestMutexJob', Class.new(MutexApplicationJob) do
def perform
with_lock('test_key') do
# Do nothing
end
end
end)
end
before do
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:locked?).and_return(false)
allow(lock_manager).to receive(:lock).and_return(true)
allow(lock_manager).to receive(:unlock).and_return(true)
end
describe '#with_lock' do
it 'acquires the lock and yields the block if lock is not acquired' do
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, &b) }.to yield_control
end
it 'acquires the lock with custom timeout' do
expect(lock_manager).to receive(:lock).with(lock_key, 5.seconds).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, 5.seconds, &b) }.to yield_control
end
it 'raises LockAcquisitionError if it cannot acquire the lock' do
allow(lock_manager).to receive(:locked?).with(lock_key).and_return(true)
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
expect do
described_class.new.send(:with_lock, lock_key) do
# Do nothing
end
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
expect(lock_manager).not_to receive(:unlock)
end
it 'raises StandardError if it execution raises it' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
allow(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
described_class.new.send(:with_lock, lock_key) do
raise StandardError
end
end.to raise_error(StandardError)
end
it 'ensures that the lock is released even if there is an error during block execution' do
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe Notification::RemoveOldNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
it 'enqueues the job' do
notification = create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
expect do
described_class.perform_later(notification)
end.to have_enqueued_job(described_class)
.on_queue('low')
end
it 'removes old notifications which are older than 1 month' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.month.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.ago)
described_class.perform_now
expect(Notification.count).to eq(2)
end
end
@@ -40,5 +40,10 @@ RSpec.describe TriggerScheduledItemsJob do
expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Notification::RemoveOldNotificationJob' do
expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once
described_class.perform_now
end
end
end
@@ -83,6 +83,7 @@ describe AutomationRuleListener do
{
attribute_key: 'customer_type',
filter_operator: 'equal_to',
custom_attribute_type: 'contact_attribute',
values: ['platinum'],
query_operator: 'AND'
}.with_indifferent_access,
@@ -154,6 +155,7 @@ describe AutomationRuleListener do
{
attribute_key: 'customer_type',
filter_operator: 'equal_to',
custom_attribute_type: 'contact_attribute',
values: ['platinum'],
query_operator: nil
}.with_indifferent_access
+1 -1
View File
@@ -11,7 +11,7 @@ RSpec.describe Imap::ImapMailbox do
imap_port: 993, imap_login: 'imap@gmail.com', imap_password: 'password',
account: account)
end
let(:inbox) { create(:inbox, channel: channel, account: account) }
let(:inbox) { channel.inbox }
let!(:contact) { create(:contact, email: 'email@gmail.com', phone_number: '+919584546666', account: account, identifier: '123') }
let(:conversation) { Conversation.where(inbox_id: channel.inbox).last }
let(:class_instance) { described_class.new }
+59 -5
View File
@@ -3,6 +3,58 @@ require 'rails_helper'
RSpec.describe Channel::Telegram do
let(:telegram_channel) { create(:channel_telegram) }
describe '#convert_markdown_to_telegram_html' do
subject { telegram_channel.send(:convert_markdown_to_telegram_html, text) }
context 'when text contains multiple newline characters' do
let(:text) { "Line one\nLine two\n\nLine four" }
it 'preserves multiple newline characters' do
expect(subject).to eq("Line one\nLine two\n\nLine four")
end
end
context 'when text contains broken markdown' do
let(:text) { 'This is a **broken markdown with <b>HTML</b> tags.' }
it 'does not break and properly converts to Telegram HTML format and escapes html tags' do
expect(subject).to eq('This is a **broken markdown with &lt;b&gt;HTML&lt;/b&gt; tags.')
end
end
context 'when text contains markdown and HTML elements' do
let(:text) { "Hello *world*! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line." }
it 'converts markdown to Telegram HTML format and escapes other html' do
expect(subject).to eq("Hello <em>world</em>! This is &lt;b&gt;bold&lt;/b&gt; and this is &lt;i&gt;italic&lt;/i&gt;.\nThis is a new line.")
end
end
context 'when text contains unsupported HTML tags' do
let(:text) { 'This is a <span>test</span> with unsupported tags.' }
it 'removes unsupported HTML tags' do
expect(subject).to eq('This is a &lt;span&gt;test&lt;/span&gt; with unsupported tags.')
end
end
context 'when text contains special characters' do
let(:text) { 'Special characters: & < >' }
it 'escapes special characters' do
expect(subject).to eq('Special characters: &amp; &lt; &gt;')
end
end
context 'when text contains markdown links' do
let(:text) { 'Check this [link](http://example.com) out!' }
it 'converts markdown links to Telegram HTML format' do
expect(subject).to eq('Check this <a href="http://example.com">link</a> out!')
end
end
end
context 'when a valid message and empty attachments' do
it 'send message' do
message = create(:message, message_type: :outgoing, content: 'test',
@@ -10,7 +62,7 @@ RSpec.describe Channel::Telegram do
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id='
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
@@ -21,13 +73,15 @@ RSpec.describe Channel::Telegram do
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send message with markdown converted to telegram markdown' do
it 'send message with markdown converted to telegram HTML' do
message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: "chat_id=123&text=#{ERB::Util.url_encode('*test* *test* ~test~')}&reply_markup=&parse_mode=Markdown&reply_to_message_id="
body: "chat_id=123&text=#{
ERB::Util.url_encode('<strong>test</strong> <em>test</em> ~test~')
}&reply_markup=&parse_mode=HTML&reply_to_message_id="
)
.to_return(
status: 200,
@@ -49,7 +103,7 @@ RSpec.describe Channel::Telegram do
.with(
body: 'chat_id=123&text=test' \
'&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=Markdown&reply_to_message_id='
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
@@ -66,7 +120,7 @@ RSpec.describe Channel::Telegram do
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id='
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 403,
+1 -1
View File
@@ -25,7 +25,7 @@ RSpec.describe Inbox do
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
it { is_expected.to have_many(:messages).through(:conversations) }
it { is_expected.to have_many(:messages).dependent(:destroy_async) }
it { is_expected.to have_one(:agent_bot_inbox) }
@@ -0,0 +1,105 @@
require 'rails_helper'
RSpec.describe AutomationRules::ConditionValidationService do
let(:account) { create(:account) }
let(:rule) { create(:automation_rule, account: account) }
describe '#perform' do
context 'with standard attributes' do
before do
rule.conditions = [
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' },
{ 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'contains' },
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' }
]
rule.save
end
it 'returns true' do
expect(described_class.new(rule).perform).to be(true)
end
end
context 'with wrong attribute' do
before do
rule.conditions = [
{ 'values': ['open'], 'attribute_key': 'not-a-standard-attribute-for-sure', 'query_operator': nil, 'filter_operator': 'equal_to' }
]
rule.save
end
it 'returns false' do
expect(described_class.new(rule).perform).to be(false)
end
end
context 'with wrong filter operator' do
before do
rule.conditions = [
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'not-a-filter-operator' }
]
rule.save
end
it 'returns false' do
expect(described_class.new(rule).perform).to be(false)
end
end
context 'with "attribute_changed" filter operator' do
before do
rule.conditions = [
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'attribute_changed' }
]
rule.save
end
it 'returns true' do
expect(described_class.new(rule).perform).to be(true)
end
end
context 'with correct custom attribute' do
before do
create(:custom_attribute_definition,
attribute_key: 'custom_attr_priority',
account: account,
attribute_model: 'conversation_attribute',
attribute_display_type: 'list',
attribute_values: %w[P0 P1 P2])
rule.conditions = [
{
'values': ['true'],
'attribute_key': 'custom_attr_priority',
'filter_operator': 'equal_to',
'custom_attribute_type': 'conversation_attribute'
}
]
rule.save
end
it 'returns true' do
expect(described_class.new(rule).perform).to be(true)
end
end
context 'with missing custom attribute' do
before do
rule.conditions = [
{
'values': ['true'],
'attribute_key': 'attribute_is_not_present', # the attribute is not present
'filter_operator': 'equal_to',
'custom_attribute_type': 'conversation_attribute'
}
]
rule.save
end
it 'returns false for missing custom attribute' do
expect(described_class.new(rule).perform).to be(false)
end
end
end
end