mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	 99997a701a
			
		
	
	99997a701a
	
	
	
		
			
			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>
		
	
		
			
				
	
	
		
			279 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			279 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup>
 | |
| import { ref, computed, onMounted, watch } from 'vue';
 | |
| import { useVuelidate } from '@vuelidate/core';
 | |
| import { requiredIf } from '@vuelidate/validators';
 | |
| import { useI18n } from 'vue-i18n';
 | |
| import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
 | |
| import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
 | |
| 
 | |
| import Input from 'dashboard/components-next/input/Input.vue';
 | |
| 
 | |
| const props = defineProps({
 | |
|   template: {
 | |
|     type: Object,
 | |
|     default: () => ({}),
 | |
|     validator: value => {
 | |
|       if (!value || typeof value !== 'object') return false;
 | |
|       if (!value.friendly_name) return false;
 | |
|       return true;
 | |
|     },
 | |
|   },
 | |
| });
 | |
| 
 | |
| const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
 | |
| 
 | |
| const VARIABLE_PATTERN = /{{([^}]+)}}/g;
 | |
| 
 | |
| const { t } = useI18n();
 | |
| 
 | |
| const processedParams = ref({});
 | |
| 
 | |
| const languageLabel = computed(() => {
 | |
|   return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
 | |
| });
 | |
| 
 | |
| const categoryLabel = computed(() => {
 | |
|   return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
 | |
| });
 | |
| 
 | |
| const templateBody = computed(() => {
 | |
|   return props.template.body || '';
 | |
| });
 | |
| 
 | |
| const hasMediaTemplate = computed(() => {
 | |
|   return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
 | |
| });
 | |
| 
 | |
| const hasVariables = computed(() => {
 | |
|   return templateBody.value?.match(VARIABLE_PATTERN) !== null;
 | |
| });
 | |
| 
 | |
| const mediaVariableKey = computed(() => {
 | |
|   if (!hasMediaTemplate.value) return null;
 | |
|   const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
 | |
|   if (!mediaUrl) return null;
 | |
|   return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
 | |
| });
 | |
| 
 | |
| const hasMediaVariable = computed(() => {
 | |
|   return hasMediaTemplate.value && mediaVariableKey.value !== null;
 | |
| });
 | |
| 
 | |
| const templateMediaUrl = computed(() => {
 | |
|   if (!hasMediaTemplate.value) return '';
 | |
| 
 | |
|   return props.template?.types?.['twilio/media']?.media?.[0] || '';
 | |
| });
 | |
| 
 | |
| const variablePattern = computed(() => {
 | |
|   if (!hasVariables.value) return [];
 | |
|   const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
 | |
|   return matches.map(match => match.replace(/[{}]/g, ''));
 | |
| });
 | |
| 
 | |
| const renderedTemplate = computed(() => {
 | |
|   let rendered = templateBody.value;
 | |
|   if (processedParams.value && Object.keys(processedParams.value).length > 0) {
 | |
|     // Replace variables in the format {{1}}, {{2}}, etc.
 | |
|     rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
 | |
|       const cleanVariable = variable.trim();
 | |
|       return processedParams.value[cleanVariable] || match;
 | |
|     });
 | |
|   }
 | |
|   return rendered;
 | |
| });
 | |
| 
 | |
| const isFormInvalid = computed(() => {
 | |
|   if (!hasVariables.value && !hasMediaVariable.value) return false;
 | |
| 
 | |
|   if (hasVariables.value) {
 | |
|     const hasEmptyVariable = variablePattern.value.some(
 | |
|       variable => !processedParams.value[variable]
 | |
|     );
 | |
|     if (hasEmptyVariable) return true;
 | |
|   }
 | |
| 
 | |
|   if (
 | |
|     hasMediaVariable.value &&
 | |
|     mediaVariableKey.value &&
 | |
|     !processedParams.value[mediaVariableKey.value]
 | |
|   ) {
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   return false;
 | |
| });
 | |
| 
 | |
| const v$ = useVuelidate(
 | |
|   {
 | |
|     processedParams: {
 | |
|       requiredIfKeysPresent: requiredIf(
 | |
|         () => hasVariables.value || hasMediaVariable.value
 | |
|       ),
 | |
|     },
 | |
|   },
 | |
|   { processedParams }
 | |
| );
 | |
| 
 | |
| const initializeTemplateParameters = () => {
 | |
|   processedParams.value = {};
 | |
| 
 | |
|   if (hasVariables.value) {
 | |
|     variablePattern.value.forEach(variable => {
 | |
|       processedParams.value[variable] = '';
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   if (hasMediaVariable.value && mediaVariableKey.value) {
 | |
|     processedParams.value[mediaVariableKey.value] = '';
 | |
|   }
 | |
| };
 | |
| 
 | |
| const sendMessage = () => {
 | |
|   v$.value.$touch();
 | |
|   if (v$.value.$invalid || isFormInvalid.value) return;
 | |
| 
 | |
|   const { friendly_name, language } = props.template;
 | |
| 
 | |
|   // Process parameters and extract filename from media URL if needed
 | |
|   const processedParameters = { ...processedParams.value };
 | |
| 
 | |
|   // For media templates, extract filename from full URL
 | |
|   if (
 | |
|     hasMediaVariable.value &&
 | |
|     mediaVariableKey.value &&
 | |
|     processedParameters[mediaVariableKey.value]
 | |
|   ) {
 | |
|     processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
 | |
|       processedParameters[mediaVariableKey.value]
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   const payload = {
 | |
|     message: renderedTemplate.value,
 | |
|     templateParams: {
 | |
|       name: friendly_name,
 | |
|       language,
 | |
|       processed_params: processedParameters,
 | |
|     },
 | |
|   };
 | |
|   emit('sendMessage', payload);
 | |
| };
 | |
| 
 | |
| const resetTemplate = () => {
 | |
|   emit('resetTemplate');
 | |
| };
 | |
| 
 | |
| const goBack = () => {
 | |
|   emit('back');
 | |
| };
 | |
| 
 | |
| onMounted(initializeTemplateParameters);
 | |
| 
 | |
| watch(
 | |
|   () => props.template,
 | |
|   () => {
 | |
|     initializeTemplateParameters();
 | |
|     v$.value.$reset();
 | |
|   },
 | |
|   { deep: true }
 | |
| );
 | |
| 
 | |
| defineExpose({
 | |
|   processedParams,
 | |
|   hasVariables,
 | |
|   hasMediaTemplate,
 | |
|   renderedTemplate,
 | |
|   v$,
 | |
|   sendMessage,
 | |
|   resetTemplate,
 | |
|   goBack,
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div>
 | |
|     <div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
 | |
|       <div class="flex justify-between items-center">
 | |
|         <h3 class="text-sm font-medium text-n-slate-12">
 | |
|           {{ template.friendly_name }}
 | |
|         </h3>
 | |
|         <span class="text-xs text-n-slate-11">
 | |
|           {{ languageLabel }}
 | |
|         </span>
 | |
|       </div>
 | |
| 
 | |
|       <div class="flex flex-col gap-2">
 | |
|         <div class="rounded-md">
 | |
|           <div class="text-sm whitespace-pre-wrap text-n-slate-12">
 | |
|             {{ renderedTemplate }}
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <div class="text-xs text-n-slate-11">
 | |
|         {{ categoryLabel }}
 | |
|       </div>
 | |
|     </div>
 | |
| 
 | |
|     <div v-if="hasVariables || hasMediaVariable">
 | |
|       <!-- Media URL for media templates -->
 | |
|       <div v-if="hasMediaVariable" class="mb-4">
 | |
|         <p class="mb-2.5 text-sm font-semibold">
 | |
|           {{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
 | |
|         </p>
 | |
|         <div class="flex items-center mb-2.5">
 | |
|           <Input
 | |
|             v-model="processedParams[mediaVariableKey]"
 | |
|             type="url"
 | |
|             class="flex-1"
 | |
|             :placeholder="
 | |
|               templateMediaUrl ||
 | |
|               t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
 | |
|             "
 | |
|           />
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <!-- Body Variables Section -->
 | |
|       <div v-if="hasVariables">
 | |
|         <p class="mb-2.5 text-sm font-semibold">
 | |
|           {{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
 | |
|         </p>
 | |
|         <div
 | |
|           v-for="variable in variablePattern"
 | |
|           :key="`variable-${variable}`"
 | |
|           class="flex items-center mb-2.5"
 | |
|         >
 | |
|           <Input
 | |
|             v-model="processedParams[variable]"
 | |
|             type="text"
 | |
|             class="flex-1"
 | |
|             :placeholder="
 | |
|               t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
 | |
|                 variable: variable,
 | |
|               })
 | |
|             "
 | |
|           />
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <p
 | |
|         v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
 | |
|         class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
 | |
|       >
 | |
|         {{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
 | |
|       </p>
 | |
|     </div>
 | |
| 
 | |
|     <slot
 | |
|       name="actions"
 | |
|       :send-message="sendMessage"
 | |
|       :reset-template="resetTemplate"
 | |
|       :go-back="goBack"
 | |
|       :is-valid="!v$.$invalid && !isFormInvalid"
 | |
|       :disabled="isFormInvalid"
 | |
|     />
 | |
|   </div>
 | |
| </template>
 |