feat(whatsapp): expose cached Twilio templates (#15311)

The cached inbox template endpoint currently serves only native WhatsApp
channels. This draft extends the same read-only endpoint to Twilio
WhatsApp inboxes and adds last-sync metadata for both providers, so the
template listing can use one stable contract without making provider
requests.

Non-WhatsApp inbox behavior remains unchanged.

Related:
https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings

### Things to know

- This is stack 1 of 2 and contains only the API contract needed by the
settings UI in #15312.
- Template data remains cache-only; the endpoint does not call Meta or
Twilio.
- Native WhatsApp templates are filtered by `name`; Twilio Content
Templates are filtered by `friendly_name`.

### How to test

1. Request the message templates endpoint for a native WhatsApp inbox
and confirm it returns the cached templates plus `meta.last_updated_at`.
2. Request it for a Twilio WhatsApp inbox and confirm it returns cached
Content Templates plus `meta.last_updated_at`.
3. Pass a template name and confirm only the matching provider template
is returned.
4. Request it for a non-WhatsApp inbox and confirm the endpoint returns
an unprocessable entity response.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-08-04 14:13:11 +04:00
committed by GitHub
parent 80c9357387
commit 1c28df49e5
7 changed files with 126 additions and 45 deletions

View File

@@ -17,12 +17,17 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
end
def message_templates
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' } unless @inbox.whatsapp?
unless whatsapp_channel?
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' }
end
templates = @inbox.channel.message_templates.presence || []
templates = templates.select { |template| template['name'] == params[:name] } if params[:name].present?
templates, last_sync_attempt_at, name_key = message_template_data
templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present?
render json: { payload: templates }
render json: {
payload: templates,
meta: { last_sync_attempt_at: last_sync_attempt_at }
}
end
def health
@@ -80,6 +85,12 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def message_template_data
return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp?
[@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name']
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)

View File

@@ -2,9 +2,9 @@ class Twilio::TemplateSyncService
pattr_initialize [:channel!]
def call
mark_templates_updated
fetch_templates_from_twilio
update_channel_templates
mark_templates_updated
rescue Twilio::REST::TwilioError => e
Rails.logger.error("Twilio template sync failed: #{e.message}")
false
@@ -13,16 +13,13 @@ class Twilio::TemplateSyncService
private
def fetch_templates_from_twilio
@templates = client.content.v1.contents.list(limit: 1000)
@templates = client.content.v1.content_and_approvals.list(limit: 1000)
end
def update_channel_templates
formatted_templates = @templates.map { |template| format_template(template) }
channel.update!(
content_templates: { templates: formatted_templates },
content_templates_last_updated: Time.current
)
channel.update!(content_templates: { templates: formatted_templates })
end
def format_template(template)
@@ -50,10 +47,8 @@ class Twilio::TemplateSyncService
@client ||= channel.send(:client)
end
def derive_status(_template)
# For now, assume all fetched templates are approved
# In the future, this could check approval status from Twilio
'approved'
def derive_status(template)
template.approval_requests&.dig('whatsapp', 'status')&.downcase || 'unsubmitted'
end
def derive_template_type(template)

View File

@@ -1275,6 +1275,7 @@ RSpec.describe 'Inboxes API', type: :request do
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/:id/message_templates' do
let(:last_sync_attempt_at) { 1.hour.ago.change(usec: 0) }
let(:message_templates) do
[
{ 'name' => 'shipping_update', 'language' => 'en_US' },
@@ -1287,6 +1288,7 @@ RSpec.describe 'Inboxes API', type: :request do
:channel_whatsapp,
account: account,
message_templates: message_templates,
message_templates_last_updated: last_sync_attempt_at,
sync_templates: false,
validate_provider_config: false
)
@@ -1319,6 +1321,7 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(message_templates)
expect(Time.zone.parse(response.parsed_body.dig('meta', 'last_sync_attempt_at'))).to eq(last_sync_attempt_at)
end
end

View File

@@ -8,17 +8,19 @@ RSpec.describe Twilio::TemplateSyncService do
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:content_api) { double }
let(:contents_list) { double }
let(:content_and_approvals_list) { double }
let(:approval_requests) { { 'whatsapp' => { 'status' => 'approved' } } }
# Mock Twilio template objects
let(:text_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX123456789',
friendly_name: 'hello_world',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
)
@@ -26,12 +28,13 @@ RSpec.describe Twilio::TemplateSyncService do
let(:media_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX987654321',
friendly_name: 'product_showcase',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: { '1' => 'iPhone', '2' => '$999' },
types: {
'twilio/media' => {
@@ -44,12 +47,13 @@ RSpec.describe Twilio::TemplateSyncService do
let(:quick_reply_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX555666777',
friendly_name: 'welcome_message',
language: 'en_US',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: {
'twilio/quick-reply' => {
@@ -65,12 +69,13 @@ RSpec.describe Twilio::TemplateSyncService do
let(:catalog_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX111222333',
friendly_name: 'product_catalog',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: {
'twilio/catalog' => {
@@ -83,12 +88,13 @@ RSpec.describe Twilio::TemplateSyncService do
let(:call_to_action_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX444555666',
friendly_name: 'payment_reminder',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: {
'twilio/call-to-action' => {
@@ -110,8 +116,8 @@ RSpec.describe Twilio::TemplateSyncService do
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
allow(twilio_client).to receive(:content).and_return(content_api)
allow(content_api).to receive(:v1).and_return(content_api)
allow(content_api).to receive(:contents).and_return(contents_list)
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
allow(content_api).to receive(:content_and_approvals).and_return(content_and_approvals_list)
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return(templates)
end
describe '#call' do
@@ -121,7 +127,7 @@ RSpec.describe Twilio::TemplateSyncService do
result = sync_service.call
expect(result).to be_truthy
expect(contents_list).to have_received(:list).with(limit: 1000)
expect(content_and_approvals_list).to have_received(:list).with(limit: 1000)
twilio_channel.reload
expect(twilio_channel.content_templates).to be_present
@@ -152,6 +158,30 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
context 'when a template has a non-approved WhatsApp status' do
let(:approval_requests) { { 'whatsapp' => { 'status' => 'rejected' } } }
it 'stores the provider approval status' do
sync_service.call
template_data = twilio_channel.reload.content_templates['templates'].first
expect(template_data['status']).to eq('rejected')
end
end
context 'when a template has not been submitted to WhatsApp' do
let(:approval_requests) { {} }
it 'stores the template as unsubmitted' do
sync_service.call
template_data = twilio_channel.reload.content_templates['templates'].first
expect(template_data['status']).to eq('unsubmitted')
end
end
it 'correctly formats media templates' do
sync_service.call
@@ -222,17 +252,18 @@ RSpec.describe Twilio::TemplateSyncService do
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX_MARKETING',
friendly_name: 'promo_offer_50_off',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
sync_service.call
@@ -244,17 +275,18 @@ RSpec.describe Twilio::TemplateSyncService do
it 'categorizes authentication templates correctly' do
auth_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX_AUTH',
friendly_name: 'otp_verification',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([auth_template])
sync_service.call
@@ -267,21 +299,24 @@ RSpec.describe Twilio::TemplateSyncService do
context 'with API error' do
before do
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
allow(content_and_approvals_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
allow(Rails.logger).to receive(:error)
end
it 'handles Twilio::REST::TwilioError gracefully' do
result = sync_service.call
freeze_time do
result = sync_service.call
expect(result).to be_falsey
expect(result).to be_falsey
expect(twilio_channel.reload.content_templates_last_updated).to eq(Time.current)
end
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
end
end
context 'with generic error' do
before do
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
allow(content_and_approvals_list).to receive(:list).and_raise(StandardError, 'Connection failed')
allow(Rails.logger).to receive(:error)
end
@@ -292,7 +327,7 @@ RSpec.describe Twilio::TemplateSyncService do
context 'with empty templates list' do
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([])
end
it 'updates channel with empty templates array' do
@@ -308,17 +343,18 @@ RSpec.describe Twilio::TemplateSyncService do
describe 'template categorization behavior' do
it 'defaults to utility category for unrecognized patterns' do
generic_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX_GENERIC',
friendly_name: 'order_status',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: { 'twilio/text' => { 'body' => 'Order updated' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([generic_template])
sync_service.call
@@ -333,12 +369,13 @@ RSpec.describe Twilio::TemplateSyncService do
context 'with multiple type definitions' do
let(:mixed_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
sid: 'HX_MIXED',
friendly_name: 'mixed_type',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
approval_requests: approval_requests,
variables: {},
types: {
'twilio/media' => { 'body' => 'Media content' },
@@ -348,7 +385,7 @@ RSpec.describe Twilio::TemplateSyncService do
end
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
end
it 'prioritizes media type for type detection but text for body extraction' do

View File

@@ -5,14 +5,14 @@ get:
summary: List WhatsApp message templates
security:
- userApiKey: []
description: List the cached message templates available for a WhatsApp inbox
description: List the cached message templates available for a native or Twilio WhatsApp inbox
parameters:
- $ref: '#/components/parameters/account_id'
- name: id
in: path
schema:
type: number
description: ID of the WhatsApp inbox
description: ID of the native or Twilio WhatsApp inbox
required: true
- name: name
in: query
@@ -30,10 +30,19 @@ get:
properties:
payload:
type: array
description: WhatsApp message templates available for the inbox
description: Native or Twilio WhatsApp message templates available for the inbox
items:
type: object
additionalProperties: true
meta:
type: object
properties:
last_sync_attempt_at:
type:
- string
- 'null'
format: date-time
description: Time when template synchronization was last attempted
'404':
description: Inbox not found
content:

View File

@@ -6106,7 +6106,7 @@
"userApiKey": []
}
],
"description": "List the cached message templates available for a WhatsApp inbox",
"description": "List the cached message templates available for a native or Twilio WhatsApp inbox",
"parameters": [
{
"$ref": "#/components/parameters/account_id"
@@ -6117,7 +6117,7 @@
"schema": {
"type": "number"
},
"description": "ID of the WhatsApp inbox",
"description": "ID of the native or Twilio WhatsApp inbox",
"required": true
},
{
@@ -6140,11 +6140,24 @@
"properties": {
"payload": {
"type": "array",
"description": "WhatsApp message templates available for the inbox",
"description": "Native or Twilio WhatsApp message templates available for the inbox",
"items": {
"type": "object",
"additionalProperties": true
}
},
"meta": {
"type": "object",
"properties": {
"last_sync_attempt_at": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Time when template synchronization was last attempted"
}
}
}
}
}

View File

@@ -4649,7 +4649,7 @@
"userApiKey": []
}
],
"description": "List the cached message templates available for a WhatsApp inbox",
"description": "List the cached message templates available for a native or Twilio WhatsApp inbox",
"parameters": [
{
"$ref": "#/components/parameters/account_id"
@@ -4660,7 +4660,7 @@
"schema": {
"type": "number"
},
"description": "ID of the WhatsApp inbox",
"description": "ID of the native or Twilio WhatsApp inbox",
"required": true
},
{
@@ -4683,11 +4683,24 @@
"properties": {
"payload": {
"type": "array",
"description": "WhatsApp message templates available for the inbox",
"description": "Native or Twilio WhatsApp message templates available for the inbox",
"items": {
"type": "object",
"additionalProperties": true
}
},
"meta": {
"type": "object",
"properties": {
"last_sync_attempt_at": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Time when template synchronization was last attempted"
}
}
}
}
}