Compare commits

..
Author SHA1 Message Date
PranavandVishnu Narayanan 43e0572f15 remove search 2026-01-20 11:22:09 +05:30
Sojan Jose 59663dd558 Merge branch 'hotfix/4.9.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2026-01-12 09:15:17 -08:00
Sojan Jose ff68c3a74f Bump version to 4.9.2 2026-01-12 09:14:25 -08:00
Shivam MishraandGitHub 58cec84b93 feat: sanitize html before assiging it to tempDiv (#13252) 2026-01-12 22:41:37 +05:30
Sojan Jose 8311657f9c Merge branch 'release/4.9.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-22 18:56:05 -08:00
Sojan Jose 73140998ff Merge branch 'release/4.9.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-19 19:12:55 -08:00
Sojan Jose 4e9f644646 Merge branch 'release/4.8.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-11-18 18:41:51 -08:00
Sojan Jose bfa8a8ed60 Merge branch 'release/4.7.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-10-15 22:11:02 -07:00
Sojan Jose 7ab60d9f9c Merge branch 'release/4.6.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-09-19 13:44:30 +05:30
Sojan Jose faac1486e9 Merge branch 'release/4.5.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 21:45:34 +02:00
Sojan Jose 8340bd615c Merge branch 'release/4.5.1'
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 16:25:15 +02:00
Sojan Jose 75d3569f22 Merge branch 'release/4.5.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-08-18 18:40:04 +02:00
Sojan Jose 545453537f Merge branch 'release/4.4.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-07-16 00:10:42 -07:00
Sojan d75702c6b2 Merge branch 'release/4.3.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-06-17 17:29:27 -07:00
Sojan b76ec878f1 Merge branch 'release/4.2.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-05-20 00:12:06 -07:00
Sojan a954e1eaca Merge branch 'release/4.1.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-04-16 23:10:46 -07:00
Sojan bc5f1722e1 Merge branch 'release/4.0.4'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-03-21 18:55:06 -07:00
Sojan 8f39e62570 Merge branch 'release/4.0.3'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-27 21:05:35 -08:00
Sojan 7520ca7a99 Merge branch 'release/4.0.2'
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-21 17:04:31 -08:00
Sojan 35f4e63605 Merge branch 'release/4.0.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-17 01:13:27 +05:30
Sojan 5773089865 Merge branch 'release/4.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-16 17:11:45 +05:30
Sojan d01c7d3fa7 Merge branch 'release/3.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-12-17 23:03:24 +05:30
Sojan 959d2c0d8c Merge branch 'release/3.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-11-19 18:02:07 +08:00
Vishnu Narayanan 622f29a0b7 Merge branch 'hotfix/3.14.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-22 16:03:47 +05:30
41 changed files with 115 additions and 1261 deletions
-2
View File
@@ -274,5 +274,3 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+1 -1
View File
@@ -1 +1 @@
4.9.1
4.9.2
@@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts(contacts)
-10
View File
@@ -32,16 +32,6 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
@@ -1,28 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -859,7 +859,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,9 +41,6 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -140,11 +140,6 @@ export const FORMATTING = {
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
@@ -28,7 +28,6 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -808,35 +808,6 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -3,22 +3,15 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@@ -28,10 +21,6 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
props.inbox?.id
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -40,19 +29,7 @@ const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
});
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const originalTemplateValues = ref({
message: '',
templateButtonText: '',
templateLanguage: '',
});
const filterTypes = [
@@ -74,59 +51,6 @@ const labelOptions = computed(() =>
: []
);
const languageOptions = computed(() =>
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
);
const messagePreviewData = computed(() => ({
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
}));
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
icon: 'i-lucide-circle-check',
color: 'text-n-teal-11',
},
PENDING: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
icon: 'i-lucide-clock',
color: 'text-n-amber-11',
},
REJECTED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
icon: 'i-lucide-circle-x',
color: 'text-n-ruby-10',
},
};
// Handle template not found case
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
icon: 'i-lucide-alert-triangle',
color: 'text-n-ruby-10',
};
}
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
}
// Default case - no template exists
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
icon: 'i-lucide-stamp',
color: 'text-n-slate-11',
};
});
const initializeState = () => {
if (!props.inbox) return;
@@ -139,63 +63,21 @@ const initializeState = () => {
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
button_text: buttonText = 'Please rate us',
language = 'en',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.templateButtonText = buttonText;
state.templateLanguage = language;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
}
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
try {
templateLoading.value = true;
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
inboxId: props.inbox.id,
});
// Handle case where template doesn't exist
if (!response.template_exists && response.error === 'Template not found') {
templateStatus.value = {
template_exists: false,
error: 'TEMPLATE_NOT_FOUND',
};
} else {
templateStatus.value = response;
}
} catch (error) {
templateStatus.value = {
template_exists: false,
error: 'API_ERROR',
};
} finally {
templateLoading.value = false;
}
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -223,49 +105,6 @@ const removeLabel = label => {
}
};
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
original.message !== state.message ||
original.templateButtonText !== state.templateButtonText ||
original.templateLanguage !== state.templateLanguage
);
};
// Check if there's an existing template
const hasExistingTemplate = () => {
const { template_exists, error } = templateStatus.value || {};
return template_exists && !error;
};
// Check if we should create a template
const shouldCreateTemplate = () => {
// Create template if no existing template
if (!hasExistingTemplate()) {
return true;
}
// Create template if there are changes to template fields
return hasTemplateChanges();
};
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
const { template_name, template_id, template, status } =
templateStatus.value || {};
return {
name: template_name,
template_id,
language: template?.language || state.templateLanguage,
status,
};
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
@@ -276,103 +115,31 @@ const updateInbox = async attributes => {
return store.dispatch('inboxes/updateInbox', payload);
};
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
});
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
return response.template;
};
const performSave = async () => {
const saveSettings = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
try {
newTemplateData = await createTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
useAlert(errorMessage);
return;
}
}
const csatConfig = {
display_type: state.displayType,
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
csatConfig.template = templateConfig;
}
}
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
checkTemplateStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
) {
confirmDialog.value?.open();
return;
}
await performSave();
};
const handleConfirmTemplateUpdate = async () => {
// We will delete the template before creating the template
await performSave();
};
</script>
<template>
@@ -388,9 +155,7 @@ const handleConfirmTemplateUpdate = async () => {
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -400,97 +165,14 @@ const handleConfirmTemplateUpdate = async () => {
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
</template>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
@@ -498,7 +180,7 @@ const handleConfirmTemplateUpdate = async () => {
>
<div class="mb-4">
<span
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
@@ -535,11 +217,7 @@ const handleConfirmTemplateUpdate = async () => {
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
</p>
<div>
<NextButton
@@ -551,11 +229,5 @@ const handleConfirmTemplateUpdate = async () => {
</div>
</div>
</SectionLayout>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
ref="confirmDialog"
@confirm="handleConfirmTemplateUpdate"
/>
</div>
</template>
@@ -1,34 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
dialogRef.value?.close();
};
defineExpose({
dialogRef,
open: () => dialogRef.value?.open(),
close: () => dialogRef.value?.close(),
});
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -83,14 +83,6 @@ export const getters = {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -352,14 +344,6 @@ export const actions = {
throw new Error(error);
}
},
createCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
return response.data;
},
getCSATTemplateStatus: async (_, { inboxId }) => {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
};
export const mutations = {
@@ -164,5 +164,4 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
};
-26
View File
@@ -15,32 +15,6 @@ class AgentBotListener < BaseListener
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_created(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_status_changed(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_updated(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def message_created(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
+5 -19
View File
@@ -322,21 +322,12 @@ class Message < ApplicationRecord
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -350,11 +341,6 @@ class Message < ApplicationRecord
sender.is_a?(User)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
+1 -1
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :text
# url :string
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
-11
View File
@@ -95,7 +95,6 @@ class MailPresenter < SimpleDelegator
content_type: content_type,
date: date,
from: from,
headers: headers_data,
html_content: html_content,
in_reply_to: in_reply_to,
message_id: message_id,
@@ -137,16 +136,6 @@ class MailPresenter < SimpleDelegator
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
end
def headers_data
headers = {
'x-original-from' => @mail['X-Original-From']&.value,
'x-original-sender' => @mail['X-Original-Sender']&.value,
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
}.compact
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
+1 -61
View File
@@ -4,9 +4,7 @@ class CsatSurveyService
def perform
return unless should_send_csat_survey?
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif within_messaging_window?
if within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
create_csat_not_sent_activity_message
@@ -37,64 +35,6 @@ class CsatSurveyService
conversation.can_reply?
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
def template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
status_result[:success] && status_result[:template][:status] == 'APPROVED'
rescue StandardError => e
Rails.logger.error "Error checking CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
message = build_csat_message
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def build_template_info(template_name, template_config)
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
}
end
def build_csat_message
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content_type: :input_csat
)
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -42,10 +42,6 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -30,10 +30,6 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
def build_content_variables(template)
case template['template_type']
when 'text', 'quick_reply', 'call_to_action'
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
when 'text', 'quick_reply'
convert_text_template(template_params) # Text and quick reply templates use body variables
when 'media'
convert_media_template(template_params)
else
@@ -63,8 +63,6 @@ class Twilio::TemplateSyncService
'media'
elsif template_types.include?('twilio/quick-reply')
'quick_reply'
elsif template_types.include?('twilio/call-to-action')
'call_to_action'
elsif template_types.include?('twilio/catalog')
'catalog'
else
@@ -109,8 +107,6 @@ class Twilio::TemplateSyncService
template_types['twilio/media']['body']
elsif template_types['twilio/quick-reply']
template_types['twilio/quick-reply']['body']
elsif template_types['twilio/call-to-action']
template_types['twilio/call-to-action']['body']
elsif template_types['twilio/catalog']
template_types['twilio/catalog']['body']
else
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.9.1'
version: '4.9.2'
development:
<<: *shared
+1 -2
View File
@@ -5,8 +5,7 @@
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('alfred', redis: redis, warning: true)
end
@@ -1,9 +0,0 @@
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
def up
change_column :webhooks, :url, :text
end
def down
change_column :webhooks, :url, :string
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.1].define(version: 2025_12_29_081141) do
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
create_table "webhooks", force: :cascade do |t|
t.integer "account_id"
t.integer "inbox_id"
t.text "url"
t.string "url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.9.1",
"version": "4.9.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
+1 -2
View File
@@ -14,9 +14,8 @@ RSpec.describe Message do
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
# Captain::Assistant responses clear waiting_since (like AgentBot)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to be_nil
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
create(:message, message_type: :outgoing, conversation: conversation)
-111
View File
@@ -82,115 +82,4 @@ describe AgentBotListener do
end
end
end
describe '#conversation_created' do
let(:event_name) { 'conversation.created' }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_created(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
conversation.webhook_data.merge(event: 'conversation_created')).once
listener.conversation_created(event)
end
end
end
describe '#conversation_updated' do
let(:event_name) { 'conversation.updated' }
let!(:event) do
Events::Base.new(
event_name,
Time.zone.now,
conversation: conversation.reload,
changed_attributes: {
custom_attributes: [{ test: nil }, { test: 'testing custom attri webhook' }]
}
)
end
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_updated(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update(custom_attributes: { test: 'testing custom attri webhook' })
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_updated',
changed_attributes: [
{
custom_attributes: {
previous_value: { test: nil },
current_value: { test: 'testing custom attri webhook' }
}
}
]
)
).once
listener.conversation_updated(event)
end
end
end
describe '#conversation_status_changed' do
let(:event_name) { 'conversation.status_changed' }
let!(:event) do
Events::Base.new(
event_name,
Time.zone.now,
conversation: conversation.reload,
changed_attributes: {
status: %w[pending open]
}
)
end
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_status_changed(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update(status: 'open')
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_status_changed',
changed_attributes: [
{
status: {
previous_value: 'pending',
current_value: 'open'
}
}
]
)
).once
listener.conversation_status_changed(event)
end
end
end
end
+3 -3
View File
@@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content
to auto_reply]
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
end
context 'with reply uuid present' do
@@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
-65
View File
@@ -978,70 +978,5 @@ RSpec.describe Conversation do
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
context 'when AgentBot responds between customer messages' do
let(:agent_bot) { create(:agent_bot, account: account) }
def create_bot_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: agent_bot,
created_at: created_at)
end
message
end
it 'calculates reply time from the most recent customer message after bot response' do
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 5.hours.ago)
# Bot responds
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after bot response) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies - should create reply_time event from customer message 2
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
it 'handles multiple bot responses before customer messages again' do
# Initial conversation: customer message -> agent first reply
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 6.hours.ago)
# Bot responds multiple times
create_bot_message(conversation, created_at: 5.hours.ago)
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after multiple bot responses) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
end
end
end
-57
View File
@@ -300,63 +300,6 @@ RSpec.describe Message do
expect(conversation.waiting_since).to eq old_waiting_since
end
context 'when bot has responded to the conversation' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
before do
# Create initial customer message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 2.hours.ago)
conversation.update(waiting_since: 2.hours.ago)
# Bot responds
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 1.hour.ago)
end
it 'resets waiting_since when customer sends a new message after bot response' do
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'does not reset waiting_since if last response was from human agent' do
# Human agent responds (clears waiting_since)
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent)
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends new message
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'clears waiting_since when bot responds' do
# After the bot response in before block, waiting_since should already be cleared
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends another message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 30.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
# Another bot response should clear it again
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 15.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
end
end
context 'with webhook_data' do
-34
View File
@@ -41,7 +41,6 @@ RSpec.describe MailPresenter do
:content_type,
:date,
:from,
:headers,
:html_content,
:in_reply_to,
:message_id,
@@ -61,39 +60,6 @@ RSpec.describe MailPresenter do
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'includes forwarded headers in serialized_data' do
mail_with_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['X-Original-From'] = 'Original <original@example.com>'
header['X-Original-Sender'] = 'original@example.com'
header['X-Forwarded-For'] = 'forwarder@example.com'
end
data = described_class.new(mail_with_headers).serialized_data
expect(data[:headers]).to eq(
'x-original-from' => 'Original <original@example.com>',
'x-original-sender' => 'original@example.com',
'x-forwarded-for' => 'forwarder@example.com'
)
end
it 'returns nil headers when forwarding headers are missing' do
mail_without_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
data = described_class.new(mail_without_headers).serialized_data
expect(data[:headers]).to be_nil
end
it 'give email from in downcased format' do
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
end
+1 -267
View File
@@ -3,9 +3,7 @@ require 'rails_helper'
describe CsatSurveyService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
let(:service) { described_class.new(conversation: conversation) }
describe '#perform' do
@@ -89,269 +87,5 @@ describe CsatSurveyService do
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
end
context 'when it is a WhatsApp channel' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
let(:whatsapp_contact) { create(:contact, account: account) }
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
let(:whatsapp_conversation) do
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
end
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
end
context 'when template is available and approved' do
before do
setup_approved_template('customer_survey_template')
end
it 'sends WhatsApp template survey instead of regular survey' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: 'customer_survey_template',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'button',
sub_type: 'url',
index: '0',
parameters: array_including(
hash_including(type: 'text', text: whatsapp_conversation.uuid)
)
)
)
),
instance_of(Message)
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
it 'updates message with returned message ID' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to eq('template_message_id_123')
end
it 'builds correct template info with default template name' do
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
allow(mock_provider_service).to receive(:get_template_status)
.with(expected_template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: expected_template_name,
lang_code: 'en'
),
anything
)
end
it 'builds CSAT message with correct attributes' do
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.account).to eq(account)
expect(csat_message.inbox).to eq(whatsapp_inbox)
expect(csat_message.message_type).to eq('outgoing')
expect(csat_message.content).to eq('Please rate your experience')
expect(csat_message.content_type).to eq('input_csat')
end
it 'uses default message when not configured' do
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation')
end
end
context 'when template is not available or not approved' do
it 'falls back to regular survey when template is pending' do
setup_template_with_status('pending_template', 'PENDING')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template is rejected' do
setup_template_with_status('pending_template', 'REJECTED')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template API call fails' do
allow(mock_provider_service).to receive(:get_template_status)
.with('pending_template')
.and_return({ success: false, error: 'Template not found' })
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template status check raises error' do
allow(mock_provider_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when no template is configured' do
it 'falls back to regular survey' do
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when template sending fails' do
before do
setup_approved_template('working_template', {
'template' => { 'name' => 'working_template' },
'message' => 'Rate us'
})
end
it 'handles template sending errors gracefully' do
mock_template_send_failure('Template send failed')
expect { whatsapp_service.perform }.not_to raise_error
# Should still create the CSAT message even if sending fails
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
it 'does not update message when send_template returns nil' do
mock_template_send_with_no_id
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
end
context 'when outside messaging window' do
before do
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
end
it 'sends template survey even when outside messaging window if template is approved' do
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
# No activity message should be created when template is successfully sent
end
it 'creates activity message when template is not available and outside window' do
whatsapp_service.perform
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
whatsapp_conversation,
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
end
end
private
def setup_approved_template(template_name, config = nil)
template_config = config || {
'template' => {
'name' => template_name,
'language' => 'en'
},
'message' => 'Please rate your experience'
}
whatsapp_inbox.update(csat_config: template_config)
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
end
def setup_template_with_status(template_name, status)
whatsapp_inbox.update(csat_config: {
'template' => { 'name' => template_name }
})
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: status } })
end
def mock_successful_template_send(message_id)
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
message_id
end
end
def mock_template_send_failure(error_message = 'Template send failed')
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
raise StandardError, error_message
end
end
def mock_template_send_with_no_id
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
nil
end
end
end
@@ -74,24 +74,6 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Instagram' do
@@ -148,24 +130,6 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Line' do
@@ -394,18 +358,6 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::TwilioSms' do
@@ -81,29 +81,7 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
let(:call_to_action_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX444555666',
friendly_name: 'payment_reminder',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/call-to-action' => {
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
'actions' => [
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
]
}
}
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
before do
allow(twilio_channel).to receive(:send).and_call_original
@@ -126,7 +104,7 @@ RSpec.describe Twilio::TemplateSyncService do
twilio_channel.reload
expect(twilio_channel.content_templates).to be_present
expect(twilio_channel.content_templates['templates']).to be_an(Array)
expect(twilio_channel.content_templates['templates'].size).to eq(5)
expect(twilio_channel.content_templates['templates'].size).to eq(4)
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
end
end
@@ -194,32 +172,6 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
it 'correctly formats call-to-action templates with variables' do
sync_service.call
twilio_channel.reload
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'payment_reminder'
end
expect(call_to_action_data).to include(
'content_sid' => 'HX444555666',
'friendly_name' => 'payment_reminder',
'language' => 'en',
'status' => 'approved',
'template_type' => 'call_to_action',
'media_type' => nil,
'variables' => {},
'category' => 'utility'
)
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
expect(call_to_action_data['body']).to eq(expected_body)
expect(call_to_action_data['body']).to match(/{{date}}/)
end
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,