mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
Implements comprehensive Twilio WhatsApp content template support (Phase
1) enabling text, media, and quick reply templates with proper parameter
conversion, sync capabilities, and feature flag protection.
### Features Implemented
**Template Types Supported**
- Basic Text Templates: Simple text with variables ({{1}}, {{2}})
- Media Templates: Image/Video/Document templates with text variables
- Quick Reply Templates: Interactive button templates
- Phase 2 (Future): List Picker, Call-to-Action, Catalog, Carousel,
Authentication templates
**Template Synchronization**
- API Endpoint: POST
/api/v1/accounts/{account_id}/inboxes/{inbox_id}/sync_templates
- Background Job: Channels::Twilio::TemplatesSyncJob
- Storage: JSONB format in channel_twilio_sms.content_templates
- Auto-categorization: UTILITY, MARKETING, AUTHENTICATION categories
### Template Examples Tested
#### Text template
```
{ "name": "greet", "language": "en" }
```
#### Template with variables
```
{ "name": "order_status", "parameters": [{"type": "body", "parameters": [{"text": "John"}]}] }
```
#### Media template with image
```
{ "name": "product_showcase", "parameters": [
{"type": "header", "parameters": [{"image": {"link": "image.jpg"}}]},
{"type": "body", "parameters": [{"text": "iPhone"}, {"text": "$999"}]}
]}
```
#### Preview
<img width="1362" height="1058" alt="CleanShot 2025-08-26 at 10 01
51@2x"
src="https://github.com/user-attachments/assets/cb280cea-08c3-44ca-8025-58a96cb3a451"
/>
<img width="1308" height="1246" alt="CleanShot 2025-08-26 at 10 02
02@2x"
src="https://github.com/user-attachments/assets/9ea8537a-61e9-40f5-844f-eaad337e1ddd"
/>
#### User guide
https://www.chatwoot.com/hc/user-guide/articles/1756195741-twilio-content-templates
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
239 lines
5.4 KiB
JavaScript
239 lines
5.4 KiB
JavaScript
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
|
import camelcaseKeys from 'camelcase-keys';
|
|
import ContactAPI from 'dashboard/api/contacts';
|
|
|
|
const CHANNEL_PRIORITY = {
|
|
'Channel::Email': 1,
|
|
'Channel::Whatsapp': 2,
|
|
'Channel::Sms': 3,
|
|
'Channel::TwilioSms': 4,
|
|
'Channel::WebWidget': 5,
|
|
'Channel::Api': 6,
|
|
};
|
|
|
|
export const generateLabelForContactableInboxesList = ({
|
|
name,
|
|
email,
|
|
channelType,
|
|
phoneNumber,
|
|
}) => {
|
|
if (channelType === INBOX_TYPES.EMAIL) {
|
|
return `${name} (${email})`;
|
|
}
|
|
if (
|
|
channelType === INBOX_TYPES.TWILIO ||
|
|
channelType === INBOX_TYPES.WHATSAPP
|
|
) {
|
|
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
|
}
|
|
return name;
|
|
};
|
|
|
|
const transformInbox = ({
|
|
name,
|
|
id,
|
|
email,
|
|
channelType,
|
|
phoneNumber,
|
|
medium,
|
|
...rest
|
|
}) => ({
|
|
id,
|
|
icon: getInboxIconByType(channelType, medium, 'line'),
|
|
label: generateLabelForContactableInboxesList({
|
|
name,
|
|
email,
|
|
channelType,
|
|
phoneNumber,
|
|
}),
|
|
action: 'inbox',
|
|
value: id,
|
|
name,
|
|
email,
|
|
phoneNumber,
|
|
channelType,
|
|
medium,
|
|
...rest,
|
|
});
|
|
|
|
export const compareInboxes = (a, b) => {
|
|
// Channels that have no priority defined should come at the end.
|
|
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
|
|
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
|
|
|
|
if (priorityA !== priorityB) {
|
|
return priorityA - priorityB;
|
|
}
|
|
|
|
const nameA = a.name || '';
|
|
const nameB = b.name || '';
|
|
return nameA.localeCompare(nameB);
|
|
};
|
|
|
|
export const buildContactableInboxesList = contactInboxes => {
|
|
if (!contactInboxes) return [];
|
|
|
|
return contactInboxes.map(transformInbox).sort(compareInboxes);
|
|
};
|
|
|
|
export const getCapitalizedNameFromEmail = email => {
|
|
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
|
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
};
|
|
|
|
export const processContactableInboxes = inboxes => {
|
|
return inboxes.map(inbox => ({
|
|
...inbox.inbox,
|
|
sourceId: inbox.sourceId,
|
|
}));
|
|
};
|
|
|
|
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
|
|
if (!inboxesData || !inboxesData.length) {
|
|
return [];
|
|
}
|
|
|
|
return inboxesData.map(inboxData => {
|
|
const matchingInbox =
|
|
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
|
|
return {
|
|
...camelcaseKeys(matchingInbox, { deep: true }),
|
|
...inboxData,
|
|
};
|
|
});
|
|
};
|
|
|
|
export const prepareAttachmentPayload = (
|
|
attachedFiles,
|
|
directUploadsEnabled
|
|
) => {
|
|
const files = [];
|
|
attachedFiles.forEach(attachment => {
|
|
if (directUploadsEnabled) {
|
|
files.push(attachment.blobSignedId);
|
|
} else {
|
|
files.push(attachment.resource.file);
|
|
}
|
|
});
|
|
return files;
|
|
};
|
|
|
|
export const prepareNewMessagePayload = ({
|
|
targetInbox,
|
|
selectedContact,
|
|
message,
|
|
subject,
|
|
ccEmails,
|
|
bccEmails,
|
|
currentUser,
|
|
attachedFiles = [],
|
|
directUploadsEnabled = false,
|
|
}) => {
|
|
const payload = {
|
|
inboxId: targetInbox.id,
|
|
sourceId: targetInbox.sourceId,
|
|
contactId: Number(selectedContact.id),
|
|
message: { content: message },
|
|
assigneeId: currentUser.id,
|
|
};
|
|
|
|
if (attachedFiles?.length) {
|
|
payload.files = prepareAttachmentPayload(
|
|
attachedFiles,
|
|
directUploadsEnabled
|
|
);
|
|
}
|
|
|
|
if (subject) {
|
|
payload.mailSubject = subject;
|
|
}
|
|
|
|
if (ccEmails) {
|
|
payload.message.cc_emails = ccEmails;
|
|
}
|
|
|
|
if (bccEmails) {
|
|
payload.message.bcc_emails = bccEmails;
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
export const prepareWhatsAppMessagePayload = ({
|
|
targetInbox,
|
|
selectedContact,
|
|
message,
|
|
templateParams,
|
|
currentUser,
|
|
}) => {
|
|
return {
|
|
inboxId: targetInbox.id,
|
|
sourceId: targetInbox.sourceId,
|
|
contactId: selectedContact.id,
|
|
message: { content: message, template_params: templateParams },
|
|
assigneeId: currentUser.id,
|
|
};
|
|
};
|
|
|
|
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
|
return {
|
|
payload: keys.map(key => {
|
|
const filterPayload = {
|
|
attribute_key: key,
|
|
filter_operator: 'contains',
|
|
values: [query],
|
|
attribute_model: 'standard',
|
|
};
|
|
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
|
filterPayload.query_operator = 'or';
|
|
}
|
|
return filterPayload;
|
|
}),
|
|
};
|
|
};
|
|
|
|
// API Calls
|
|
export const searchContacts = async ({ keys, query }) => {
|
|
const {
|
|
data: { payload },
|
|
} = await ContactAPI.filter(
|
|
undefined,
|
|
'name',
|
|
generateContactQuery({ keys, query })
|
|
);
|
|
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
|
// Filter contacts that have either phone_number or email
|
|
const filteredPayload = camelCasedPayload?.filter(
|
|
contact => contact.phoneNumber || contact.email
|
|
);
|
|
return filteredPayload || [];
|
|
};
|
|
|
|
export const createNewContact = async input => {
|
|
const payload = {
|
|
name: input.startsWith('+')
|
|
? input.slice(1) // Remove the '+' prefix if it exists
|
|
: getCapitalizedNameFromEmail(input),
|
|
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
|
|
};
|
|
|
|
const {
|
|
data: {
|
|
payload: { contact: newContact },
|
|
},
|
|
} = await ContactAPI.create(payload);
|
|
|
|
return camelcaseKeys(newContact, { deep: true });
|
|
};
|
|
|
|
export const fetchContactableInboxes = async contactId => {
|
|
const {
|
|
data: { payload: inboxes = [] },
|
|
} = await ContactAPI.getContactableInboxes(contactId);
|
|
|
|
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
|
|
|
|
return processContactableInboxes(convertInboxesToCamelKeys);
|
|
};
|