-
- {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
-
{{ getTemplatebody(template) }}
+
+ {{ getTemplateHeader(template).text }}
+
+
+ {{
+ t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
+ format: getTemplateHeader(template).format,
+ }) ||
+ `${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
+ }}
+
-
-
- {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
-
{{ template.category }}
+
{{ getTemplateBody(template) }}
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
+
+
+ {{ getTemplateFooter(template).text }}
+
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
+
+
+
+ {{ button.text }}
+
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
+
+
{{ template.category }}
@@ -128,13 +191,13 @@ export default {
- {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
+ {{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
{{ query }}
- {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
+ {{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
diff --git a/app/javascript/dashboard/composables/spec/useAutomation.spec.js b/app/javascript/dashboard/composables/spec/useAutomation.spec.js
index 46c0bde50..a66f0c7b4 100644
--- a/app/javascript/dashboard/composables/spec/useAutomation.spec.js
+++ b/app/javascript/dashboard/composables/spec/useAutomation.spec.js
@@ -196,6 +196,7 @@ describe('useAutomation', () => {
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
+ automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
diff --git a/app/javascript/dashboard/constants/automation.js b/app/javascript/dashboard/constants/automation.js
index 399dd6153..a903075b8 100644
--- a/app/javascript/dashboard/constants/automation.js
+++ b/app/javascript/dashboard/constants/automation.js
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
},
];
-export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
+export const DEFAULT_CONVERSATION_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index c9852814a..3723fd4d5 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -5,7 +5,7 @@ import {
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
- DEFAULT_CONVERSATION_OPENED_CONDITION,
+ DEFAULT_CONVERSATION_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
} from 'dashboard/constants/automation';
@@ -169,8 +169,11 @@ export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
- if (eventName === 'conversation_opened') {
- return DEFAULT_CONVERSATION_OPENED_CONDITION;
+ if (
+ eventName === 'conversation_opened' ||
+ eventName === 'conversation_resolved'
+ ) {
+ return DEFAULT_CONVERSATION_CONDITION;
}
return DEFAULT_OTHER_CONDITION;
};
diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
new file mode 100644
index 000000000..6e0661152
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
@@ -0,0 +1,368 @@
+import {
+ replaceTemplateVariables,
+ buildTemplateParameters,
+ processVariable,
+ allKeysRequired,
+} from '../templateHelper';
+import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
+
+describe('templateHelper', () => {
+ const technicianTemplate = templates.find(t => t.name === 'technician_visit');
+
+ describe('processVariable', () => {
+ it('should remove curly braces from variables', () => {
+ expect(processVariable('{{name}}')).toBe('name');
+ expect(processVariable('{{1}}')).toBe('1');
+ expect(processVariable('{{customer_id}}')).toBe('customer_id');
+ });
+ });
+
+ describe('allKeysRequired', () => {
+ it('should return true when all keys have values', () => {
+ const obj = { name: 'John', age: '30' };
+ expect(allKeysRequired(obj)).toBe(true);
+ });
+
+ it('should return false when some keys are empty', () => {
+ const obj = { name: 'John', age: '' };
+ expect(allKeysRequired(obj)).toBe(false);
+ });
+
+ it('should return true for empty object', () => {
+ expect(allKeysRequired({})).toBe(true);
+ });
+ });
+
+ describe('replaceTemplateVariables', () => {
+ const templateText =
+ "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
+
+ it('should replace all variables with provided values', () => {
+ const processedParams = {
+ body: {
+ 1: 'John',
+ 2: '123 Main St',
+ 3: '2025-01-15',
+ 4: '10:00 AM',
+ 5: '2:00 PM',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toBe(
+ "Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
+ );
+ });
+
+ it('should keep original variable format when no replacement value provided', () => {
+ const processedParams = {
+ body: {
+ 1: 'John',
+ 3: '2025-01-15',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toContain('John');
+ expect(result).toContain('2025-01-15');
+ expect(result).toContain('{{2}}');
+ expect(result).toContain('{{4}}');
+ expect(result).toContain('{{5}}');
+ });
+
+ it('should handle empty processedParams', () => {
+ const result = replaceTemplateVariables(templateText, {});
+ expect(result).toBe(templateText);
+ });
+ });
+
+ describe('buildTemplateParameters', () => {
+ it('should build parameters for template with body variables', () => {
+ const result = buildTemplateParameters(technicianTemplate, false);
+
+ expect(result.body).toEqual({
+ 1: '',
+ 2: '',
+ 3: '',
+ 4: '',
+ 5: '',
+ });
+ });
+
+ it('should include header parameters when hasMediaHeader is true', () => {
+ const imageTemplate = templates.find(
+ t => t.name === 'order_confirmation'
+ );
+ const result = buildTemplateParameters(imageTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'image',
+ });
+ });
+
+ it('should not include header parameters when hasMediaHeader is false', () => {
+ const result = buildTemplateParameters(technicianTemplate, false);
+ expect(result.header).toBeUndefined();
+ });
+
+ it('should handle template with no body component', () => {
+ const templateWithoutBody = {
+ components: [{ type: 'HEADER', format: 'TEXT' }],
+ };
+
+ const result = buildTemplateParameters(templateWithoutBody, false);
+ expect(result).toEqual({});
+ });
+
+ it('should handle template with no variables', () => {
+ const templateWithoutVars = templates.find(
+ t => t.name === 'no_variable_template'
+ );
+ const result = buildTemplateParameters(templateWithoutVars, false);
+
+ expect(result.body).toBeUndefined();
+ });
+
+ it('should handle URL buttons with variables for non-authentication templates', () => {
+ const templateWithUrlButton = {
+ category: 'MARKETING',
+ components: [
+ {
+ type: 'BODY',
+ text: 'Check out our website at {{site_url}}',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ type: 'URL',
+ url: 'https://example.com/{{campaign_id}}',
+ text: 'Visit Site',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(templateWithUrlButton, false);
+ expect(result.buttons).toEqual([
+ {
+ type: 'url',
+ parameter: '',
+ url: 'https://example.com/{{campaign_id}}',
+ variables: ['campaign_id'],
+ },
+ ]);
+ });
+
+ it('should handle templates with no variables', () => {
+ const emptyTemplate = templates.find(
+ t => t.name === 'no_variable_template'
+ );
+ const result = buildTemplateParameters(emptyTemplate, false);
+ expect(result).toEqual({});
+ });
+
+ it('should build parameters for templates with multiple component types', () => {
+ const complexTemplate = {
+ components: [
+ { type: 'HEADER', format: 'IMAGE' },
+ { type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
+ { type: 'FOOTER', text: 'Thank you for your business' },
+ {
+ type: 'BUTTONS',
+ buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(complexTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'image',
+ });
+ expect(result.body).toEqual({ 1: '', 2: '' });
+ expect(result.buttons).toEqual([
+ {
+ type: 'url',
+ parameter: '',
+ url: 'https://example.com/{{3}}',
+ variables: ['3'],
+ },
+ ]);
+ });
+
+ it('should handle copy code buttons correctly', () => {
+ const copyCodeTemplate = templates.find(
+ t => t.name === 'discount_coupon'
+ );
+ const result = buildTemplateParameters(copyCodeTemplate, false);
+
+ expect(result.body).toBeDefined();
+ expect(result.buttons).toEqual([
+ {
+ type: 'copy_code',
+ parameter: '',
+ },
+ ]);
+ });
+
+ it('should handle templates with document headers', () => {
+ const documentTemplate = templates.find(
+ t => t.name === 'purchase_receipt'
+ );
+ const result = buildTemplateParameters(documentTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'document',
+ });
+ expect(result.body).toEqual({
+ 1: '',
+ 2: '',
+ 3: '',
+ });
+ });
+
+ it('should handle video header templates', () => {
+ const videoTemplate = templates.find(t => t.name === 'training_video');
+ const result = buildTemplateParameters(videoTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'video',
+ });
+ expect(result.body).toEqual({
+ name: '',
+ date: '',
+ });
+ });
+ });
+
+ describe('enhanced format validation', () => {
+ it('should validate enhanced format structure', () => {
+ const processedParams = {
+ body: { 1: 'John', 2: 'Order123' },
+ header: {
+ media_url: 'https://example.com/image.jpg',
+ media_type: 'image',
+ },
+ buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
+ };
+
+ // Test that structure is properly formed
+ expect(processedParams.body).toBeDefined();
+ expect(typeof processedParams.body).toBe('object');
+ expect(processedParams.header).toBeDefined();
+ expect(Array.isArray(processedParams.buttons)).toBe(true);
+ });
+
+ it('should handle empty component sections', () => {
+ const processedParams = {
+ body: {},
+ header: {},
+ buttons: [],
+ };
+
+ expect(allKeysRequired(processedParams.body)).toBe(true);
+ expect(allKeysRequired(processedParams.header)).toBe(true);
+ expect(processedParams.buttons.length).toBe(0);
+ });
+
+ it('should validate parameter completeness', () => {
+ const incompleteParams = {
+ body: { 1: 'John', 2: '' },
+ };
+
+ expect(allKeysRequired(incompleteParams.body)).toBe(false);
+ });
+
+ it('should handle edge cases in processVariable', () => {
+ expect(processVariable('{{')).toBe('');
+ expect(processVariable('}}')).toBe('');
+ expect(processVariable('')).toBe('');
+ expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
+ });
+
+ it('should handle special characters in template variables', () => {
+ /* eslint-disable no-template-curly-in-string */
+ const templateText =
+ 'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
+ /* eslint-enable no-template-curly-in-string */
+ const processedParams = {
+ body: {
+ user_name: 'John & Jane',
+ order_id: '12345',
+ amount: '99.99',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toBe(
+ 'Welcome John & Jane, your order #12345 costs $99.99'
+ );
+ });
+
+ it('should handle templates with mixed parameter types', () => {
+ const mixedTemplate = {
+ components: [
+ { type: 'HEADER', format: 'VIDEO' },
+ { type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
+ { type: 'FOOTER', text: 'Thank you' },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ { type: 'URL', url: 'https://track.com/{{order_id}}' },
+ { type: 'COPY_CODE' },
+ { type: 'PHONE_NUMBER', phone_number: '+1234567890' },
+ ],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(mixedTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'video',
+ });
+ expect(result.body).toEqual({
+ order_id: '',
+ status: '',
+ });
+ expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
+ expect(result.buttons[0].type).toBe('url');
+ expect(result.buttons[1].type).toBe('copy_code');
+ });
+
+ it('should handle templates with no processable components', () => {
+ const emptyTemplate = {
+ components: [
+ { type: 'HEADER', format: 'TEXT', text: 'Static Header' },
+ { type: 'BODY', text: 'Static body with no variables' },
+ { type: 'FOOTER', text: 'Static footer' },
+ ],
+ };
+
+ const result = buildTemplateParameters(emptyTemplate, false);
+ expect(result).toEqual({});
+ });
+
+ it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
+ const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
+ const partialParams = {
+ body: {
+ name: 'John',
+ // order_id missing
+ status: 'ready',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, partialParams);
+ expect(result).toBe('Hi John, order {{order_id}} is ready');
+ expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
+ });
+ });
+});
diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js
new file mode 100644
index 000000000..5c9bbff05
--- /dev/null
+++ b/app/javascript/dashboard/helper/templateHelper.js
@@ -0,0 +1,91 @@
+// Constants
+export const DEFAULT_LANGUAGE = 'en';
+export const DEFAULT_CATEGORY = 'UTILITY';
+export const COMPONENT_TYPES = {
+ HEADER: 'HEADER',
+ BODY: 'BODY',
+ BUTTONS: 'BUTTONS',
+};
+export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
+
+export const findComponentByType = (template, type) =>
+ template.components?.find(component => component.type === type);
+
+export const processVariable = str => {
+ return str.replace(/{{|}}/g, '');
+};
+
+export const allKeysRequired = value => {
+ const keys = Object.keys(value);
+ return keys.every(key => value[key]);
+};
+
+export const replaceTemplateVariables = (templateText, processedParams) => {
+ return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
+ const variableKey = processVariable(variable);
+ return processedParams.body?.[variableKey] || `{{${variable}}}`;
+ });
+};
+
+export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
+ const allVariables = {};
+
+ const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
+ const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
+
+ if (!bodyComponent) return allVariables;
+
+ const templateString = bodyComponent.text;
+
+ // Process body variables
+ const matchedVariables = templateString.match(/{{([^}]+)}}/g);
+ if (matchedVariables) {
+ allVariables.body = {};
+ matchedVariables.forEach(variable => {
+ const key = processVariable(variable);
+ allVariables.body[key] = '';
+ });
+ }
+
+ if (hasMediaHeaderValue) {
+ if (!allVariables.header) allVariables.header = {};
+ allVariables.header.media_url = '';
+ allVariables.header.media_type = headerComponent.format.toLowerCase();
+ }
+
+ // Process button variables
+ const buttonComponents = template.components.filter(
+ component => component.type === COMPONENT_TYPES.BUTTONS
+ );
+
+ buttonComponents.forEach(buttonComponent => {
+ if (buttonComponent.buttons) {
+ buttonComponent.buttons.forEach((button, index) => {
+ // Handle URL buttons with variables
+ if (button.type === 'URL' && button.url && button.url.includes('{{')) {
+ const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
+ if (buttonVars.length > 0) {
+ if (!allVariables.buttons) allVariables.buttons = [];
+ allVariables.buttons[index] = {
+ type: 'url',
+ parameter: '',
+ url: button.url,
+ variables: buttonVars.map(v => processVariable(v)),
+ };
+ }
+ }
+
+ // Handle copy code buttons
+ if (button.type === 'COPY_CODE') {
+ if (!allVariables.buttons) allVariables.buttons = [];
+ allVariables.buttons[index] = {
+ type: 'copy_code',
+ parameter: '',
+ };
+ }
+ });
+ }
+ });
+
+ return allVariables;
+};
diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json
index cf63de81c..80274f488 100644
--- a/app/javascript/dashboard/i18n/locale/en/automation.json
+++ b/app/javascript/dashboard/i18n/locale/en/automation.json
@@ -131,6 +131,7 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
+ "CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
index 4887d07b6..5f53faaa8 100644
--- a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
+++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
@@ -1,29 +1,46 @@
{
- "WHATSAPP_TEMPLATES": {
- "MODAL": {
- "TITLE": "Whatsapp Templates",
- "SUBTITLE": "Select the whatsapp template you want to send",
- "TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
- },
- "PICKER": {
- "SEARCH_PLACEHOLDER": "Search Templates",
- "NO_TEMPLATES_FOUND": "No templates found for",
- "NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
- "REFRESH_BUTTON": "Refresh templates",
- "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
- "REFRESH_ERROR": "Failed to refresh templates. Please try again.",
- "LABELS": {
- "LANGUAGE": "Language",
- "TEMPLATE_BODY": "Template Body",
- "CATEGORY": "Category"
- }
- },
- "PARSER": {
- "VARIABLES_LABEL": "Variables",
- "VARIABLE_PLACEHOLDER": "Enter {variable} value",
- "GO_BACK_LABEL": "Go Back",
- "SEND_MESSAGE_LABEL": "Send Message",
- "FORM_ERROR_MESSAGE": "Please fill all variables before sending"
- }
+ "WHATSAPP_TEMPLATES": {
+ "MODAL": {
+ "TITLE": "Whatsapp Templates",
+ "SUBTITLE": "Select the whatsapp template you want to send",
+ "TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
+ },
+ "PICKER": {
+ "SEARCH_PLACEHOLDER": "Search Templates",
+ "NO_TEMPLATES_FOUND": "No templates found for",
+ "HEADER": "Header",
+ "BODY": "Body",
+ "FOOTER": "Footer",
+ "BUTTONS": "Buttons",
+ "CATEGORY": "Category",
+ "MEDIA_CONTENT": "Media Content",
+ "MEDIA_CONTENT_FALLBACK": "media content",
+ "NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
+ "REFRESH_BUTTON": "Refresh templates",
+ "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
+ "REFRESH_ERROR": "Failed to refresh templates. Please try again.",
+ "LABELS": {
+ "LANGUAGE": "Language",
+ "TEMPLATE_BODY": "Template Body",
+ "CATEGORY": "Category"
+ }
+ },
+ "PARSER": {
+ "VARIABLES_LABEL": "Variables",
+ "LANGUAGE": "Language",
+ "CATEGORY": "Category",
+ "VARIABLE_PLACEHOLDER": "Enter {variable} value",
+ "GO_BACK_LABEL": "Go Back",
+ "SEND_MESSAGE_LABEL": "Send Message",
+ "FORM_ERROR_MESSAGE": "Please fill all variables before sending",
+ "MEDIA_HEADER_LABEL": "{type} Header",
+ "OTP_CODE": "Enter 4-8 digit OTP",
+ "EXPIRY_MINUTES": "Enter expiry minutes",
+ "BUTTON_PARAMETERS": "Button Parameters",
+ "BUTTON_LABEL": "Button {index}",
+ "COUPON_CODE": "Enter coupon code (max 15 chars)",
+ "MEDIA_URL_LABEL": "Enter {type} URL",
+ "BUTTON_PARAMETER": "Enter button parameter"
}
+ }
}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
index dfa6163d8..0a6905039 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
},
],
},
+ conversation_resolved: {
+ conditions: [
+ {
+ key: 'browser_language',
+ name: 'BROWSER_LANGUAGE',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'email',
+ name: 'EMAIL',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'mail_subject',
+ name: 'MAIL_SUBJECT',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'country_code',
+ name: 'COUNTRY_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'referer',
+ name: 'REFERER_LINK',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'assignee_id',
+ name: 'ASSIGNEE_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_3,
+ },
+ {
+ key: 'phone_number',
+ name: 'PHONE_NUMBER',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_6,
+ },
+ {
+ key: 'team_id',
+ name: 'TEAM_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_3,
+ },
+ {
+ key: 'inbox_id',
+ name: 'INBOX',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'conversation_language',
+ name: 'CONVERSATION_LANGUAGE',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'priority',
+ name: 'PRIORITY',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ ],
+ actions: [
+ {
+ key: 'assign_agent',
+ name: 'ASSIGN_AGENT',
+ },
+ {
+ key: 'assign_team',
+ name: 'ASSIGN_TEAM',
+ },
+ {
+ key: 'send_email_to_team',
+ name: 'SEND_EMAIL_TO_TEAM',
+ },
+ {
+ key: 'send_message',
+ name: 'SEND_MESSAGE',
+ },
+ {
+ key: 'send_email_transcript',
+ name: 'SEND_EMAIL_TRANSCRIPT',
+ },
+ {
+ key: 'send_webhook_event',
+ name: 'SEND_WEBHOOK_EVENT',
+ },
+ {
+ key: 'send_attachment',
+ name: 'SEND_ATTACHMENT',
+ },
+ ],
+ },
};
export const AUTOMATION_RULE_EVENTS = [
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
key: 'conversation_updated',
value: 'CONVERSATION_UPDATED',
},
+ {
+ key: 'conversation_resolved',
+ value: 'CONVERSATION_RESOLVED',
+ },
{
key: 'message_created',
value: 'MESSAGE_CREATED',
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index a6b93c923..c4789a7a9 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -44,15 +44,52 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
- // filtering out the whatsapp templates with media
- if (messagesTemplates instanceof Array) {
- return messagesTemplates.filter(template => {
- return !template.components.some(
- i => i.format === 'IMAGE' || i.format === 'VIDEO'
- );
- });
+ return messagesTemplates;
+ },
+ getFilteredWhatsAppTemplates: $state => inboxId => {
+ const [inbox] = $state.records.filter(
+ record => record.id === Number(inboxId)
+ );
+
+ const {
+ message_templates: whatsAppMessageTemplates,
+ additional_attributes: additionalAttributes,
+ } = inbox || {};
+
+ const { message_templates: apiInboxMessageTemplates } =
+ additionalAttributes || {};
+ const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
+
+ if (!templates || !Array.isArray(templates)) {
+ return [];
}
- return [];
+
+ return templates.filter(template => {
+ // Ensure template has required properties
+ if (!template || !template.status || !template.components) {
+ return false;
+ }
+
+ // Only show approved templates
+ if (template.status.toLowerCase() !== 'approved') {
+ return false;
+ }
+
+ // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
+ const hasUnsupportedComponents = template.components.some(
+ component =>
+ ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
+ component.type
+ ) ||
+ (component.type === 'HEADER' && component.format === 'LOCATION')
+ );
+
+ if (hasUnsupportedComponents) {
+ return false;
+ }
+
+ return true;
+ });
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
index f9ed57d63..eeb52b1dc 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
@@ -1,5 +1,6 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
+import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -93,4 +94,269 @@ describe('#getters', () => {
provider: 'default',
});
});
+
+ describe('getFilteredWhatsAppTemplates', () => {
+ it('returns empty array when inbox not found', () => {
+ const state = { records: [] };
+ expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
+ });
+
+ it('returns empty array when templates is null or undefined', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: null,
+ additional_attributes: { message_templates: undefined },
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('returns empty array when templates is not an array', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: 'invalid',
+ additional_attributes: {},
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('filters out templates without required properties', () => {
+ const invalidTemplates = [
+ { name: 'incomplete_template' }, // missing status and components
+ { status: 'approved' }, // missing name and components
+ { name: 'another_incomplete', status: 'approved' }, // missing components
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: invalidTemplates,
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('filters out non-approved templates', () => {
+ const mixedStatusTemplates = [
+ {
+ name: 'pending_template',
+ status: 'pending',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ {
+ name: 'rejected_template',
+ status: 'rejected',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ {
+ name: 'approved_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: mixedStatusTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('approved_template');
+ });
+
+ it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
+ const interactiveTemplates = [
+ {
+ name: 'list_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Choose an option' },
+ { type: 'LIST', sections: [] },
+ ],
+ },
+ {
+ name: 'product_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Product info' },
+ { type: 'PRODUCT', catalog_id: '123' },
+ ],
+ },
+ {
+ name: 'catalog_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Catalog' },
+ { type: 'CATALOG', thumbnail_product_retailer_id: '123' },
+ ],
+ },
+ {
+ name: 'regular_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Regular message' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: interactiveTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('regular_template');
+ });
+
+ it('filters out location templates', () => {
+ const locationTemplates = [
+ {
+ name: 'location_template',
+ status: 'approved',
+ components: [
+ { type: 'HEADER', format: 'LOCATION' },
+ { type: 'BODY', text: 'Location message' },
+ ],
+ },
+ {
+ name: 'regular_template',
+ status: 'approved',
+ components: [
+ { type: 'HEADER', format: 'TEXT', text: 'Header' },
+ { type: 'BODY', text: 'Regular message' },
+ ],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: locationTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('regular_template');
+ });
+
+ it('returns valid templates from fixture data', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: templates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+
+ // All templates in fixtures should be approved and valid
+ expect(result.length).toBeGreaterThan(0);
+
+ // Verify all returned templates are approved
+ result.forEach(template => {
+ expect(template.status).toBe('approved');
+ expect(template.components).toBeDefined();
+ expect(Array.isArray(template.components)).toBe(true);
+ });
+
+ // Verify specific templates from fixtures are included
+ const templateNames = result.map(t => t.name);
+ expect(templateNames).toContain('sample_flight_confirmation');
+ expect(templateNames).toContain('sample_issue_resolution');
+ expect(templateNames).toContain('sample_shipping_confirmation');
+ expect(templateNames).toContain('no_variable_template');
+ expect(templateNames).toContain('order_confirmation');
+ });
+
+ it('prioritizes message_templates over additional_attributes.message_templates', () => {
+ const primaryTemplates = [
+ {
+ name: 'primary_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Primary' }],
+ },
+ ];
+
+ const fallbackTemplates = [
+ {
+ name: 'fallback_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Fallback' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: primaryTemplates,
+ additional_attributes: {
+ message_templates: fallbackTemplates,
+ },
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('primary_template');
+ });
+
+ it('falls back to additional_attributes.message_templates when message_templates is null', () => {
+ const fallbackTemplates = [
+ {
+ name: 'fallback_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Fallback' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: null,
+ additional_attributes: {
+ message_templates: fallbackTemplates,
+ },
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('fallback_template');
+ });
+ });
});
diff --git a/app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
similarity index 50%
rename from app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js
rename to app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
index 02c24b2bb..c4c6a0b40 100644
--- a/app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
@@ -260,4 +260,285 @@ export const templates = [
],
rejected_reason: 'NONE',
},
+ {
+ name: 'order_confirmation',
+ status: 'approved',
+ category: 'TICKET_UPDATE',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ example: {
+ header_handle: ['https://example.com/shoes.jpg'],
+ },
+ },
+ {
+ text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'technician_visit',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Technician visit',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Confirm',
+ type: 'QUICK_REPLY',
+ },
+ {
+ text: 'Reschedule',
+ type: 'QUICK_REPLY',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'event_invitation_static',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://events.example.com/register',
+ text: 'Visit website',
+ type: 'URL',
+ },
+ {
+ url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
+ text: 'Get Directions',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'purchase_receipt',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'DOCUMENT',
+ },
+ {
+ text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'discount_coupon',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Copy offer code',
+ type: 'COPY_CODE',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'support_callback',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Call Support',
+ type: 'PHONE_NUMBER',
+ phone_number: '+16506677566',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'training_video',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'VIDEO',
+ },
+ {
+ text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'product_launch',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ },
+ {
+ text: 'New arrival! Our stunning coat now available in {{color}} color.',
+ type: 'BODY',
+ },
+ {
+ text: 'Free shipping on orders over $100. Limited time offer.',
+ type: 'FOOTER',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'greet',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hey {{customer_name}} how may I help you?',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'hello_world',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hello World',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ type: 'BODY',
+ },
+ {
+ text: 'WhatsApp Business Platform sample message',
+ type: 'FOOTER',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'feedback_request',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://feedback.example.com/survey',
+ text: 'Leave Feedback',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'address_update',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Address update',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'delivery_confirmation',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
];
diff --git a/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js b/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js
deleted file mode 100644
index adad15bf7..000000000
--- a/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
-import { templates } from './fixtures';
-import { nextTick } from 'vue';
-
-const config = {
- global: {
- stubs: {
- NextButton: { template: '
' },
- WootInput: { template: '
' },
- },
- },
-};
-
-describe('#WhatsAppTemplates', () => {
- it('returns all variables from a template string', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[0] },
- });
- await nextTick();
- expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
- });
-
- it('returns no variables from a template string if it does not contain variables', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[12] },
- });
- await nextTick();
- expect(wrapper.vm.variables).toBeNull();
- });
-
- it('returns the body of a template', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[1] },
- });
- await nextTick();
- const expectedOutput =
- templates[1].components.find(i => i.type === 'BODY')?.text || '';
- expect(wrapper.vm.templateString).toEqual(expectedOutput);
- });
-
- it('generates the templates from variable input', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[0] },
- });
- await nextTick();
-
- // Instead of using `setData`, directly modify the `processedParams` using the component's logic
- await wrapper.vm.$nextTick();
- wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
- await wrapper.vm.$nextTick();
-
- const expectedOutput =
- 'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
- expect(wrapper.vm.processedString).toEqual(expectedOutput);
- });
-});
diff --git a/app/listeners/automation_rule_listener.rb b/app/listeners/automation_rule_listener.rb
index 6974e227a..0515d6952 100644
--- a/app/listeners/automation_rule_listener.rb
+++ b/app/listeners/automation_rule_listener.rb
@@ -1,53 +1,18 @@
class AutomationRuleListener < BaseListener
def conversation_updated(event)
- return if performed_by_automation?(event)
-
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_updated', account)
-
- rules = current_account_rules('conversation_updated', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ process_conversation_event(event, 'conversation_updated')
end
def conversation_created(event)
- return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
-
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_created', account)
-
- rules = current_account_rules('conversation_created', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- ::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ process_conversation_event(event, 'conversation_created')
end
def conversation_opened(event)
- return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
+ process_conversation_event(event, 'conversation_opened')
+ end
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_opened', account)
-
- rules = current_account_rules('conversation_opened', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ def conversation_resolved(event)
+ process_conversation_event(event, 'conversation_resolved')
end
def message_created(event)
@@ -69,6 +34,28 @@ class AutomationRuleListener < BaseListener
end
end
+ private
+
+ def process_conversation_event(event, event_name)
+ return if performed_by_automation?(event)
+
+ auto_reply_skip_events = %w[conversation_created conversation_opened]
+ return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
+
+ conversation = event.data[:conversation]
+ account = conversation.account
+ changed_attributes = event.data[:changed_attributes]
+
+ return unless rule_present?(event_name, account)
+
+ rules = current_account_rules(event_name, account)
+
+ rules.each do |rule|
+ conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
+ AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
+ end
+ end
+
def rule_present?(event_name, account)
return if account.blank?
diff --git a/app/models/account_user.rb b/app/models/account_user.rb
index d559ab5b4..bbcb0e010 100644
--- a/app/models/account_user.rb
+++ b/app/models/account_user.rb
@@ -2,24 +2,26 @@
#
# Table name: account_users
#
-# id :bigint not null, primary key
-# active_at :datetime
-# auto_offline :boolean default(TRUE), not null
-# availability :integer default("online"), not null
-# role :integer default("agent")
-# created_at :datetime not null
-# updated_at :datetime not null
-# account_id :bigint
-# custom_role_id :bigint
-# inviter_id :bigint
-# user_id :bigint
+# id :bigint not null, primary key
+# active_at :datetime
+# auto_offline :boolean default(TRUE), not null
+# availability :integer default("online"), not null
+# role :integer default("agent")
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint
+# agent_capacity_policy_id :bigint
+# custom_role_id :bigint
+# inviter_id :bigint
+# user_id :bigint
#
# Indexes
#
-# index_account_users_on_account_id (account_id)
-# index_account_users_on_custom_role_id (custom_role_id)
-# index_account_users_on_user_id (user_id)
-# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
+# index_account_users_on_account_id (account_id)
+# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
+# index_account_users_on_custom_role_id (custom_role_id)
+# index_account_users_on_user_id (user_id)
+# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
#
class AccountUser < ApplicationRecord
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 8c5750148..42ca79d6c 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -62,7 +62,12 @@ class Attachment < ApplicationRecord
def thumb_url
return '' unless file.attached? && image?
- url_for(file.representation(resize_to_fill: [250, nil]))
+ begin
+ url_for(file.representation(resize_to_fill: [250, nil]))
+ rescue ActiveStorage::UnrepresentableError => e
+ Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
+ ''
+ end
end
def with_attached_file?
diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb
index 9878107c1..125ad9113 100644
--- a/app/services/notification/push_notification_service.rb
+++ b/app/services/notification/push_notification_service.rb
@@ -68,14 +68,23 @@ class Notification::PushNotificationService
WebPush.payload_send(**browser_push_payload(subscription))
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
- rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
- Rails.logger.info "WebPush subscription expired: #{e.message}"
- subscription.destroy!
- rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
- Rails.logger.error "WebPush operation error: #{e.message}"
rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
- true
+ handle_browser_push_error(e, subscription)
+ end
+
+ def handle_browser_push_error(error, subscription)
+ case error
+ when WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized
+ Rails.logger.info "WebPush subscription expired: #{error.message}"
+ subscription.destroy!
+ when WebPush::TooManyRequests
+ Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
+ when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
+ Rails.logger.error "WebPush operation error: #{error.message}"
+ else
+ ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
+ true
+ end
end
def send_fcm_push(subscription)
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
index 94ad5c7d1..0aed8dba0 100644
--- a/app/services/whatsapp/incoming_message_base_service.rb
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -92,6 +92,9 @@ class Whatsapp::IncomingMessageBaseService
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
+
+ # Update existing contact name if ProfileName is available and current name is just phone number
+ update_contact_with_profile_name(contact_params)
end
def set_conversation
@@ -171,4 +174,21 @@ class Whatsapp::IncomingMessageBaseService
)
end
end
+
+ def update_contact_with_profile_name(contact_params)
+ profile_name = contact_params.dig(:profile, :name)
+ return if profile_name.blank?
+ return if @contact.name == profile_name
+
+ # Only update if current name exactly matches the phone number or formatted phone number
+ return unless contact_name_matches_phone_number?
+
+ @contact.update!(name: profile_name)
+ end
+
+ def contact_name_matches_phone_number?
+ phone_number = "+#{@processed_params[:messages].first[:from]}"
+ formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
+ @contact.name == phone_number || @contact.name == formatted_phone_number
+ end
end
diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb
index 47a971f41..de2713ac0 100644
--- a/app/services/whatsapp/oneoff_campaign_service.rb
+++ b/app/services/whatsapp/oneoff_campaign_service.rb
@@ -84,7 +84,7 @@ class Whatsapp::OneoffCampaignService
namespace: namespace,
lang_code: lang_code,
parameters: processed_parameters
- })
+ }, nil)
rescue StandardError => e
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb
index 97665f7ef..9fd1f6267 100644
--- a/app/services/whatsapp/providers/base_service.rb
+++ b/app/services/whatsapp/providers/base_service.rb
@@ -15,7 +15,7 @@ class Whatsapp::Providers::BaseService
raise 'Overwrite this method in child class'
end
- def send_template(_phone_number, _template_info)
+ def send_template(_phone_number, _template_info, _message)
raise 'Overwrite this method in child class'
end
@@ -31,27 +31,27 @@ class Whatsapp::Providers::BaseService
raise 'Overwrite this method in child class'
end
- def process_response(response)
+ def process_response(response, message)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
parsed_response['messages'].first['id']
else
- handle_error(response)
+ handle_error(response, message)
nil
end
end
- def handle_error(response)
+ def handle_error(response, message)
Rails.logger.error response.body
- return if @message.blank?
+ return if message.blank?
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
error_message = error_message(response)
return if error_message.blank?
- @message.external_error = error_message
- @message.status = :failed
- @message.save!
+ message.external_error = error_message
+ message.status = :failed
+ message.save!
end
def create_buttons(items)
diff --git a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
index beb11d556..352f2d246 100644
--- a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
@@ -10,7 +10,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
end
end
- def send_template(phone_number, template_info)
+ def send_template(phone_number, template_info, message)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
@@ -21,7 +21,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
def sync_templates
@@ -68,7 +68,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
def send_attachment_message(phone_number, message)
@@ -90,7 +90,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
def error_message(response)
@@ -123,6 +123,6 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
end
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 34939048a..68e965595 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -11,7 +11,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
end
- def send_template(phone_number, template_info)
+ def send_template(phone_number, template_info, message)
template_body = template_body_parameters(template_info)
request_body = {
@@ -28,7 +28,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
body: request_body.to_json
)
- process_response(response)
+ process_response(response, message)
end
def sync_templates
@@ -92,7 +92,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
def send_attachment_message(phone_number, message)
@@ -115,7 +115,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
def error_message(response)
@@ -179,6 +179,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
}.to_json
)
- process_response(response)
+ process_response(response, message)
end
end
diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb
index 5f91bce16..20419c0cd 100644
--- a/app/services/whatsapp/send_on_whatsapp_service.rb
+++ b/app/services/whatsapp/send_on_whatsapp_service.rb
@@ -33,7 +33,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
namespace: namespace,
lang_code: lang_code,
parameters: processed_parameters
- })
+ }, message)
message.update!(source_id: message_id) if message_id.present?
end
diff --git a/app/services/whatsapp/template_parameter_converter_service.rb b/app/services/whatsapp/template_parameter_converter_service.rb
index b9a9d55d9..641a29b95 100644
--- a/app/services/whatsapp/template_parameter_converter_service.rb
+++ b/app/services/whatsapp/template_parameter_converter_service.rb
@@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
body_params = convert_hash_to_body_params(legacy_params)
enhanced['body'] = body_params unless body_params.empty?
+ when NilClass
+ # Templates without parameters (nil processed_params)
+ # Return empty enhanced structure
else
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
end
diff --git a/db/migrate/20250806140000_create_assignment_policies.rb b/db/migrate/20250806140000_create_assignment_policies.rb
new file mode 100644
index 000000000..c02e3d6de
--- /dev/null
+++ b/db/migrate/20250806140000_create_assignment_policies.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
+ def change
+ create_table :assignment_policies do |t|
+ t.references :account, null: false, index: true
+ t.string :name, null: false, limit: 255
+ t.text :description
+ t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
+ t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
+ t.integer :fair_distribution_limit, null: false, default: 100
+ t.integer :fair_distribution_window, null: false, default: 3600 # seconds
+ t.boolean :enabled, null: false, default: true
+
+ t.timestamps
+ end
+
+ add_index :assignment_policies, [:account_id, :name], unique: true
+ add_index :assignment_policies, :enabled
+ end
+end
diff --git a/db/migrate/20250806140001_create_inbox_assignment_policies.rb b/db/migrate/20250806140001_create_inbox_assignment_policies.rb
new file mode 100644
index 000000000..37fd6e37e
--- /dev/null
+++ b/db/migrate/20250806140001_create_inbox_assignment_policies.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
+ def change
+ create_table :inbox_assignment_policies do |t|
+ t.references :inbox, null: false, index: { unique: true }
+ t.references :assignment_policy, null: false, index: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250806140002_create_agent_capacity_policies.rb b/db/migrate/20250806140002_create_agent_capacity_policies.rb
new file mode 100644
index 000000000..4fdeabc92
--- /dev/null
+++ b/db/migrate/20250806140002_create_agent_capacity_policies.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1]
+ def change
+ create_table :agent_capacity_policies do |t|
+ t.references :account, null: false, index: true
+ t.string :name, null: false, limit: 255
+ t.text :description
+ t.jsonb :exclusion_rules, default: {}, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250806140003_create_inbox_capacity_limits.rb b/db/migrate/20250806140003_create_inbox_capacity_limits.rb
new file mode 100644
index 000000000..c107ce182
--- /dev/null
+++ b/db/migrate/20250806140003_create_inbox_capacity_limits.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1]
+ def change
+ create_table :inbox_capacity_limits do |t|
+ t.references :agent_capacity_policy, null: false, index: true
+ t.references :inbox, null: false, index: true
+ t.integer :conversation_limit, null: false
+
+ t.timestamps
+ end
+
+ add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true
+ end
+end
diff --git a/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb b/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb
new file mode 100644
index 000000000..53bc8e8f9
--- /dev/null
+++ b/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
+ def change
+ add_reference :account_users, :agent_capacity_policy, null: true, index: true
+ end
+end
diff --git a/db/migrate/20250806140005_create_leaves.rb b/db/migrate/20250806140005_create_leaves.rb
new file mode 100644
index 000000000..982ec1181
--- /dev/null
+++ b/db/migrate/20250806140005_create_leaves.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class CreateLeaves < ActiveRecord::Migration[7.1]
+ def change
+ create_table :leaves do |t|
+ t.references :account, null: false
+ t.references :user, null: false
+ t.date :start_date, null: false
+ t.date :end_date, null: false
+ t.integer :leave_type, null: false, default: 0
+ t.integer :status, null: false, default: 0
+ t.text :reason
+ t.references :approved_by
+ t.datetime :approved_at
+
+ t.timestamps
+ end
+
+ add_index :leaves, [:account_id, :status]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6391e3fcf..7f44c373c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.integer "availability", default: 0, null: false
t.boolean "auto_offline", default: true, null: false
t.bigint "custom_role_id"
+ t.bigint "agent_capacity_policy_id"
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
t.index ["account_id"], name: "index_account_users_on_account_id"
+ t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
t.index ["user_id"], name: "index_account_users_on_user_id"
end
@@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.index ["account_id"], name: "index_agent_bots_on_account_id"
end
+ create_table "agent_capacity_policies", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "name", limit: 255, null: false
+ t.text "description"
+ t.jsonb "exclusion_rules", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_agent_capacity_policies_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
@@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.index ["views"], name: "index_articles_on_views"
end
+ create_table "assignment_policies", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "name", limit: 255, null: false
+ t.text "description"
+ t.integer "assignment_order", default: 0, null: false
+ t.integer "conversation_priority", default: 0, null: false
+ t.integer "fair_distribution_limit", default: 100, null: false
+ t.integer "fair_distribution_window", default: 3600, null: false
+ t.boolean "enabled", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
+ t.index ["account_id"], name: "index_assignment_policies_on_account_id"
+ t.index ["enabled"], name: "index_assignment_policies_on_enabled"
+ end
+
create_table "attachments", id: :serial, force: :cascade do |t|
t.integer "file_type", default: 0
t.string "external_url"
@@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.datetime "updated_at", null: false
end
+ create_table "inbox_assignment_policies", force: :cascade do |t|
+ t.bigint "inbox_id", null: false
+ t.bigint "assignment_policy_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
+ t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id", unique: true
+ end
+
+ create_table "inbox_capacity_limits", force: :cascade do |t|
+ t.bigint "agent_capacity_policy_id", null: false
+ t.bigint "inbox_id", null: false
+ t.integer "conversation_limit", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["agent_capacity_policy_id", "inbox_id"], name: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true
+ t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id"
+ t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id"
+ end
+
create_table "inbox_members", id: :serial, force: :cascade do |t|
t.integer "user_id", null: false
t.integer "inbox_id", null: false
@@ -800,6 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end
+ create_table "leaves", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.bigint "user_id", null: false
+ t.date "start_date", null: false
+ t.date "end_date", null: false
+ t.integer "leave_type", default: 0, null: false
+ t.integer "status", default: 0, null: false
+ t.text "reason"
+ t.bigint "approved_by_id"
+ t.datetime "approved_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status"
+ t.index ["account_id"], name: "index_leaves_on_account_id"
+ t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
+ t.index ["user_id"], name: "index_leaves_on_user_id"
+ end
+
create_table "macros", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "name", null: false
diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb
index ad2bab241..dae7eb04f 100644
--- a/spec/factories/channel/channel_whatsapp.rb
+++ b/spec/factories/channel/channel_whatsapp.rb
@@ -63,6 +63,25 @@ FactoryBot.define do
],
'sub_category' => 'CUSTOM',
'parameter_format' => 'NAMED'
+ },
+ {
+ 'name' => 'test_no_params_template',
+ 'status' => 'APPROVED',
+ 'category' => 'UTILITY',
+ 'language' => 'en',
+ 'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ 'id' => '9876543210987654',
+ 'length' => 1,
+ 'parameter_format' => 'POSITIONAL',
+ 'previous_category' => 'MARKETING',
+ 'sub_category' => 'CUSTOM',
+ 'components' => [
+ {
+ 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂',
+ 'type' => 'BODY'
+ }
+ ],
+ 'rejected_reason' => 'NONE'
}]
end
message_templates_last_updated { Time.now.utc }
diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb
index 9edd9a34d..21f042f1f 100644
--- a/spec/jobs/webhooks/instagram_events_job_spec.rb
+++ b/spec/jobs/webhooks/instagram_events_job_spec.rb
@@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do
end
let!(:account) { create(:account) }
- let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
- let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
- let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
- let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
- # Combined message events into one helper
- let(:message_events) do
- {
- dm: build(:instagram_message_create_event).with_indifferent_access,
- standby: build(:instagram_message_standby_event).with_indifferent_access,
- unsend: build(:instagram_message_unsend_event).with_indifferent_access,
- attachment: build(:instagram_message_attachment_event).with_indifferent_access,
- story_mention: build(:instagram_story_mention_event).with_indifferent_access,
- story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
- messaging_seen: build(:messaging_seen_event).with_indifferent_access,
- unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
- }
- end
def return_object_for(sender_id)
{ name: 'Jane',
@@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do
describe '#perform' do
context 'when handling messaging events for Instagram via Facebook page' do
+ let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
+ let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let(:fb_object) { double }
- before do
- instagram_inbox.destroy
- end
-
it 'creates incoming message in the instagram inbox' do
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:dm][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates standby message in the instagram inbox' do
+ standby_event = build(:instagram_message_standby_event).with_indifferent_access
+ sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:standby][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(standby_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
+ unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
+ sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
+
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
@@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_messenger_inbox.messages.count).to be 1
- instagram_webhook.perform_now(message_events[:unsend][:entry])
+ instagram_webhook.perform_now(unsend_event[:entry])
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
@@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox' do
+ attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
+ sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:attachment][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
@@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox for story mention' do
+ story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
+ sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access,
{ story:
@@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do
id: 'instagram-message-id-1234' }.with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:story_mention][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(story_mention_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
@@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Facebook API call fails' do
+ story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
- instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
@@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle messaging_seen callback' do
- expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
+ messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
+
+ expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
- instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
+ instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
+ unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
+ sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:unsupported][:entry])
- instagram_messenger_inbox.reload
-
+ instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
@@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do
end
context 'when handling messaging events for Instagram via Instagram login' do
+ let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
+ let!(:instagram_inbox) { instagram_channel.inbox }
+
before do
instagram_channel.update(access_token: 'valid_instagram_token')
@@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
- instagram_webhook.perform_now(message_events[:dm][:entry])
- instagram_inbox.reload
-
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
@@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'sets correct instagram attributes on contact' do
- instagram_webhook.perform_now(message_events[:dm][:entry])
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ instagram_webhook.perform_now(dm_event[:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
@@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
+ unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
+
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
@@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
- instagram_webhook.perform_now(message_events[:unsend][:entry])
+ instagram_webhook.perform_now(unsend_event[:entry])
message.reload
@@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram direct inbox' do
- instagram_webhook.perform_now(message_events[:attachment][:entry])
-
- instagram_inbox.reload
+ attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
+ instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
@@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles unsupported message' do
- instagram_webhook.perform_now(message_events[:unsupported][:entry])
- instagram_inbox.reload
-
+ unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
+ instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
@@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Instagram API call fails' do
+ story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
+
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
- instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
-
- instagram_inbox.reload
+ instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_inbox.contacts.count).to be 0
expect(instagram_inbox.contact_inboxes.count).to be 0
@@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles messaging_seen callback' do
- expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
+ messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
+
+ expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
- instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
+ instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
- sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
- instagram_webhook.perform_now(message_events[:dm][:entry])
-
- instagram_inbox.reload
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
diff --git a/spec/listeners/automation_rule_listener_spec.rb b/spec/listeners/automation_rule_listener_spec.rb
index e1c365f94..57a096a10 100644
--- a/spec/listeners/automation_rule_listener_spec.rb
+++ b/spec/listeners/automation_rule_listener_spec.rb
@@ -130,6 +130,42 @@ describe AutomationRuleListener do
end
end
+ describe 'conversation_resolved' do
+ let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_resolved', account: account) }
+ let(:event) do
+ Events::Base.new('conversation_resolved', Time.zone.now, { conversation: conversation,
+ changed_attributes: { status: %w[Snoozed Open] } })
+ end
+
+ context 'when matching rules are present' do
+ it 'calls AutomationRules::ActionService if conditions match' do
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
+ end
+
+ it 'does not call AutomationRules::ActionService if conditions do not match' do
+ allow(condition_match).to receive(:present?).and_return(false)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
+ end
+
+ it 'calls AutomationRules::ActionService for each rule when multiple rules are present' do
+ create(:automation_rule, event_name: 'conversation_resolved', account: account)
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).to have_received(:new).twice
+ end
+
+ it 'does not call AutomationRules::ActionService if performed by automation' do
+ event.data[:performed_by] = automation_rule
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
+ end
+ end
+ end
+
describe 'message_created' do
let!(:automation_rule) { create(:automation_rule, event_name: 'message_created', account: account) }
let!(:message) { create(:message, account: account, conversation: conversation) }
diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb
index 0b03a56ad..cc00eab5d 100644
--- a/spec/models/attachment_spec.rb
+++ b/spec/models/attachment_spec.rb
@@ -82,6 +82,16 @@ RSpec.describe Attachment do
expect(attachment.thumb_url).to be_present
end
+
+ it 'handles unrepresentable images gracefully' do
+ attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
+
+ allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
+
+ expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
+ expect(attachment.thumb_url).to eq('')
+ end
end
describe 'meta data handling' do
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index 4035a47df..ede1ba824 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -371,5 +371,100 @@ describe Whatsapp::IncomingMessageService do
Redis::Alfred.delete(key)
end
end
+
+ context 'when profile name is available for contact updates' do
+ let(:wa_id) { '1234567890' }
+ let(:phone_number) { "+#{wa_id}" }
+
+ it 'updates existing contact name when current name matches phone number' do
+ # Create contact with phone number as name
+ existing_contact = create(:contact,
+ account: whatsapp_channel.inbox.account,
+ name: phone_number,
+ phone_number: phone_number)
+ create(:contact_inbox,
+ contact: existing_contact,
+ inbox: whatsapp_channel.inbox,
+ source_id: wa_id)
+
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
+ 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
+ 'timestamp' => '1633034394', 'type' => 'text' }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ existing_contact.reload
+ expect(existing_contact.name).to eq('Jane Smith')
+ end
+
+ it 'does not update contact name when current name is different from phone number' do
+ # Create contact with human name
+ existing_contact = create(:contact,
+ account: whatsapp_channel.inbox.account,
+ name: 'John Doe',
+ phone_number: phone_number)
+ create(:contact_inbox,
+ contact: existing_contact,
+ inbox: whatsapp_channel.inbox,
+ source_id: wa_id)
+
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
+ 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
+ 'timestamp' => '1633034394', 'type' => 'text' }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ existing_contact.reload
+ expect(existing_contact.name).to eq('John Doe') # Should not change
+ end
+
+ it 'updates contact name when current name matches formatted phone number' do
+ formatted_number = TelephoneNumber.parse(phone_number).international_number
+
+ # Create contact with formatted phone number as name
+ existing_contact = create(:contact,
+ account: whatsapp_channel.inbox.account,
+ name: formatted_number,
+ phone_number: phone_number)
+ create(:contact_inbox,
+ contact: existing_contact,
+ inbox: whatsapp_channel.inbox,
+ source_id: wa_id)
+
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Alice Johnson' }, 'wa_id' => wa_id }],
+ 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
+ 'timestamp' => '1633034394', 'type' => 'text' }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ existing_contact.reload
+ expect(existing_contact.name).to eq('Alice Johnson')
+ end
+
+ it 'does not update when profile name is blank' do
+ # Create contact with phone number as name
+ existing_contact = create(:contact,
+ account: whatsapp_channel.inbox.account,
+ name: phone_number,
+ phone_number: phone_number)
+ create(:contact_inbox,
+ contact: existing_contact,
+ inbox: whatsapp_channel.inbox,
+ source_id: wa_id)
+
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => '' }, 'wa_id' => wa_id }],
+ 'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
+ 'timestamp' => '1633034394', 'type' => 'text' }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ existing_contact.reload
+ expect(existing_contact.name).to eq(phone_number) # Should not change
+ end
+ end
end
end
diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb
index 599081e23..dd8d51c54 100644
--- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb
+++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb
@@ -133,7 +133,8 @@ describe Whatsapp::OneoffCampaignService do
)
)
)
- )
+ ),
+ nil
)
described_class.new(campaign: campaign).perform
@@ -164,8 +165,8 @@ describe Whatsapp::OneoffCampaignService do
allow(whatsapp_channel).to receive(:send_template).and_return(nil)
- expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything).and_raise(StandardError, error_message)
- expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything).once
+ expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything, nil).and_raise(StandardError, error_message)
+ expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything, nil).once
expect(Rails.logger).to receive(:error)
.with("Failed to send WhatsApp template message to #{contact_error.phone_number}: #{error_message}")
diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
index 8735ccfbb..69ba69379 100644
--- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
+++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
@@ -187,7 +187,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
- expect(service.send_template('+123456789', template_info)).to eq('message_id')
+ expect(service.send_template('+123456789', template_info, message)).to eq('message_id')
end
end
end
@@ -287,7 +287,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
context 'when there is a message' do
it 'logs error and updates message status' do
service.instance_variable_set(:@message, message)
- service.send(:handle_error, error_response_object)
+ service.send(:handle_error, error_response_object, message)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq(error_message)
@@ -305,7 +305,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
it 'logs error but does not update message' do
service.instance_variable_set(:@message, message)
- service.send(:handle_error, error_response_object)
+ service.send(:handle_error, error_response_object, message)
expect(message.reload.status).not_to eq('failed')
expect(message.reload.external_error).to be_nil
diff --git a/spec/services/whatsapp/template_parameter_converter_service_spec.rb b/spec/services/whatsapp/template_parameter_converter_service_spec.rb
index 2994bb472..570c5c6cc 100644
--- a/spec/services/whatsapp/template_parameter_converter_service_spec.rb
+++ b/spec/services/whatsapp/template_parameter_converter_service_spec.rb
@@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do
end
end
+ context 'when processed_params is nil (parameter-less templates)' do
+ let(:nil_params) do
+ {
+ 'processed_params' => nil
+ }
+ end
+
+ let(:parameterless_template) do
+ {
+ 'name' => 'test_no_params_template',
+ 'language' => 'en',
+ 'parameter_format' => 'POSITIONAL',
+ 'id' => '9876543210987654',
+ 'status' => 'APPROVED',
+ 'category' => 'UTILITY',
+ 'previous_category' => 'MARKETING',
+ 'sub_category' => 'CUSTOM',
+ 'components' => [
+ {
+ 'type' => 'BODY',
+ 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂'
+ }
+ ]
+ }
+ end
+
+ it 'converts nil to empty enhanced format' do
+ converter = described_class.new(nil_params, parameterless_template)
+ result = converter.normalize_to_enhanced
+
+ expect(result['processed_params']).to eq({})
+ expect(result['format_version']).to eq('legacy')
+ end
+
+ it 'does not raise ArgumentError for nil processed_params' do
+ expect do
+ converter = described_class.new(nil_params, parameterless_template)
+ converter.normalize_to_enhanced
+ end.not_to raise_error
+ end
+ end
+
context 'when invalid format' do
let(:invalid_params) do
{
@@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do
end
describe 'simplified conversion methods' do
+ describe '#convert_legacy_to_enhanced' do
+ it 'handles nil processed_params without raising error' do
+ converter = described_class.new({}, template)
+ result = converter.send(:convert_legacy_to_enhanced, nil, template)
+ expect(result).to eq({})
+ end
+
+ it 'returns empty hash for parameter-less templates' do
+ parameterless_template = {
+ 'name' => 'no_params_template',
+ 'language' => 'en',
+ 'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }]
+ }
+
+ converter = described_class.new({}, parameterless_template)
+ result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template)
+ expect(result).to eq({})
+ end
+ end
+
describe '#convert_array_to_body_params' do
it 'converts empty array' do
converter = described_class.new({}, template)
diff --git a/swagger/definitions/request/automation_rule/create_update_payload.yml b/swagger/definitions/request/automation_rule/create_update_payload.yml
index 091fa2aaa..75aedd41b 100644
--- a/swagger/definitions/request/automation_rule/create_update_payload.yml
+++ b/swagger/definitions/request/automation_rule/create_update_payload.yml
@@ -13,6 +13,7 @@ properties:
enum:
- conversation_created
- conversation_updated
+ - conversation_resolved
- message_created
example: message_created
description: The event when you want to execute the automation actions
diff --git a/swagger/definitions/request/conversation/create_message_payload.yml b/swagger/definitions/request/conversation/create_message_payload.yml
index 4b1851293..71d073d75 100644
--- a/swagger/definitions/request/conversation/create_message_payload.yml
+++ b/swagger/definitions/request/conversation/create_message_payload.yml
@@ -30,22 +30,64 @@ properties:
example: 1
template_params:
type: object
- description: The template params for the message in case of whatsapp Channel
+ description: WhatsApp template parameters for sending structured messages
+ required:
+ - name
+ - category
+ - language
+ - processed_params
properties:
name:
type: string
- description: Name of the template
- example: 'sample_issue_resolution'
+ description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager)
+ example: 'purchase_receipt'
category:
type: string
+ enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION']
description: Category of the template
- example: UTILITY
+ example: 'UTILITY'
language:
type: string
- description: Language of the template
- example: en_US
+ description: Language code of the template (BCP 47 format)
+ example: 'en_US'
processed_params:
type: object
- description: The processed param values for template variables in template
- example:
- 1: 'Chatwoot'
\ No newline at end of file
+ description: Processed template parameters organized by component type
+ properties:
+ body:
+ type: object
+ description: Body component parameters with variable placeholders
+ additionalProperties:
+ type: string
+ example:
+ '1': 'Visa'
+ '2': 'Nike'
+ '3': 'Bill'
+ header:
+ type: object
+ description: Header component parameters for media templates
+ properties:
+ media_url:
+ type: string
+ format: uri
+ description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers
+ example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
+ media_type:
+ type: string
+ enum: ['image', 'video', 'document']
+ description: Type of media for the header
+ example: 'document'
+ buttons:
+ type: array
+ description: Button component parameters for interactive templates
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ enum: ['url', 'copy_code']
+ description: Type of button parameter
+ parameter:
+ type: string
+ description: Dynamic parameter value for the button
+ example: 'SSFSDFSD'
\ No newline at end of file
diff --git a/swagger/definitions/resource/automation_rule.yml b/swagger/definitions/resource/automation_rule.yml
index b561441ff..ad96c58ff 100644
--- a/swagger/definitions/resource/automation_rule.yml
+++ b/swagger/definitions/resource/automation_rule.yml
@@ -10,4 +10,4 @@ properties:
- type: object
description: Single automation rule (for show/create/update endpoints)
allOf:
- - $ref: '#/components/schemas/automation_rule_item'
\ No newline at end of file
+ - $ref: '#/components/schemas/automation_rule_item'
diff --git a/swagger/paths/application/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml
index f8cd35f3c..1b8272585 100644
--- a/swagger/paths/application/conversation/messages/create.yml
+++ b/swagger/paths/application/conversation/messages/create.yml
@@ -2,7 +2,57 @@ tags:
- Messages
operationId: create-a-new-message-in-a-conversation
summary: Create New Message
-description: Create a new message in the conversation
+description: |
+ Create a new message in the conversation.
+
+ ## WhatsApp Template Messages
+
+ For WhatsApp channels, you can send structured template messages using the `template_params` field.
+ Templates must be pre-approved in WhatsApp Business Manager.
+
+ ### Example Templates
+
+ **Text with Image Header:**
+ ```json
+ {
+ "content": "Hi your order 121212 is confirmed. Please wait for further updates",
+ "template_params": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ },
+ "header": {
+ "media_url": "https://picsum.photos/200/300",
+ "media_type": "image"
+ }
+ }
+ }
+ }
+ ```
+
+ **Text with Copy Code Button:**
+ ```json
+ {
+ "content": "Special offer! Get 30% off your next purchase. Use the code below",
+ "template_params": {
+ "name": "discount_coupon",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "discount_percentage": "30"
+ },
+ "buttons": [{
+ "type": "copy_code",
+ "parameter": "SAVE20"
+ }]
+ }
+ }
+ }
+ ```
security:
- userApiKey: []
- agentBotApiKey: []
diff --git a/swagger/swagger.json b/swagger/swagger.json
index e849f8119..aa7455e7f 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -5937,7 +5937,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
- "description": "Create a new message in the conversation",
+ "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -10148,28 +10148,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
@@ -10542,6 +10610,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index f06819d1d..a36443f81 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -4334,7 +4334,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
- "description": "Create a new message in the conversation",
+ "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -8509,28 +8509,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
@@ -8903,6 +8971,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index c6a3ba408..16c1d5bc7 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -3132,28 +3132,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
@@ -3526,6 +3594,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index e2a16245b..c8a5294d3 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -2547,28 +2547,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
@@ -2941,6 +3009,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index b4feb0024..f816b8c94 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -3308,28 +3308,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
@@ -3702,6 +3770,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",