{{ $t('globals.terms.createdOn') }}
@@ -182,6 +195,7 @@ import ContactDetail from '@/layouts/contact/ContactDetail.vue'
import api from '@/api'
import ContactForm from '@/features/contact/ContactForm.vue'
import ContactNotes from '@/features/contact/ContactNotes.vue'
+import WhatsAppIcon from '@/components/icons/WhatsAppIcon.vue'
import { createFormSchema } from '@/features/contact/formSchema.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js
index bcf53e0d3..6973f2c10 100644
--- a/frontend/cypress.config.js
+++ b/frontend/cypress.config.js
@@ -1,9 +1,27 @@
+/* eslint-env node */
import { defineConfig } from 'cypress'
+import { start, control } from './cypress/support/metaMock.mjs'
+
+const metaMockPort = Number(process.env.META_MOCK_PORT) || 9099
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
- baseUrl: 'http://localhost:9000'
+ baseUrl: 'http://localhost:9000',
+ async setupNodeEvents(on) {
+ // The app reaches this stand-in Graph API when whatsapp.api_url points at this port.
+ await start(metaMockPort).catch((err) => {
+ if (err.code !== 'EADDRINUSE') throw err
+ })
+ on('task', {
+ 'metaMock:reset': control.reset,
+ 'metaMock:requests': control.requests,
+ 'metaMock:failSend': control.failSend,
+ 'metaMock:failValidate': control.failValidate,
+ 'metaMock:putMedia': control.putMedia,
+ 'metaMock:sign': control.sign
+ })
+ }
},
component: {
specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
diff --git a/frontend/cypress/e2e/api/whatsapp.cy.js b/frontend/cypress/e2e/api/whatsapp.cy.js
new file mode 100644
index 000000000..4c8b23865
--- /dev/null
+++ b/frontend/cypress/e2e/api/whatsapp.cy.js
@@ -0,0 +1,196 @@
+// Credentials here are dummies: saving a WhatsApp inbox always makes Meta verify them, so a create is expected to be rejected.
+
+describe('API: whatsapp', () => {
+ const stamp = Date.now()
+ const name = `api.whatsapp.${stamp}`
+
+ const config = {
+ phone_number_id: `pn-${stamp}`,
+ waba_id: `waba-${stamp}`,
+ access_token: 'dummy-access-token',
+ app_secret: 'dummy-app-secret',
+ webhook_verify_token: `verify-${stamp}`,
+ api_version: 'v25.0'
+ }
+
+ const createInbox = (overrides, options = {}) =>
+ cy.api('POST', '/api/v1/inboxes', {
+ name,
+ channel: 'whatsapp',
+ enabled: true,
+ config: { ...config, ...overrides }
+ }, { failOnStatusCode: false, ...options })
+
+ before(() => cy.login())
+ beforeEach(() => cy.login())
+
+ it('rejects an inbox with no phone number id', () => {
+ createInbox({ phone_number_id: '' }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/phone_number_id/i)
+ })
+ })
+
+ it('rejects an inbox with no waba id', () => {
+ createInbox({ waba_id: '' }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/waba_id/i)
+ })
+ })
+
+ // Without the verify token Meta's webhook handshake can never succeed, so inbound would stay dead.
+ it('rejects an inbox with no webhook verify token', () => {
+ createInbox({ webhook_verify_token: '' }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/webhook_verify_token/i)
+ })
+ })
+
+ it('rejects an inbox with no access token', () => {
+ createInbox({ access_token: '' }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/access_token/i)
+ })
+ })
+
+ // The app secret signs inbound webhooks, so without it every delivery is rejected.
+ it('rejects an inbox with no app secret', () => {
+ createInbox({ app_secret: '' }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/app_secret/i)
+ })
+ })
+
+ it('rejects an inbox whose credentials Meta does not accept', () => {
+ // Deterministic either way: the stand-in Graph API is told to refuse, and real Meta refuses anyway.
+ cy.task('metaMock:failValidate', true)
+ createInbox({}, { timeout: 90000 }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ })
+ cy.task('metaMock:failValidate', false)
+ })
+
+ it('does not create an inbox for any of the rejected attempts', () => {
+ cy.api('GET', '/api/v1/inboxes').then(({ status, body }) => {
+ expect(status).to.eq(200)
+ expect(body.data.filter((inbox) => inbox.name === name)).to.have.length(0)
+ })
+ })
+
+ it('requires an inbox id to list templates', () => {
+ cy.api('GET', '/api/v1/whatsapp/templates', null, { failOnStatusCode: false }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/inbox_id/i)
+ })
+ })
+
+ it('returns not found for an unknown template', () => {
+ cy.api('GET', '/api/v1/whatsapp/templates/99999999', null, { failOnStatusCode: false })
+ .then(({ status, body }) => {
+ expect(status).to.eq(404)
+ expect(body.error_type).to.eq('NotFoundException')
+ })
+ })
+
+ it('rejects a template with no inbox, name, language, category or body', () => {
+ cy.api('POST', '/api/v1/whatsapp/templates', { name: 'no_inbox' }, { failOnStatusCode: false })
+ .then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ expect(body.message).to.match(/inbox_id/i)
+ })
+ })
+
+ it('requires an inbox id to sync templates', () => {
+ cy.api('POST', '/api/v1/whatsapp/templates/sync', null, { failOnStatusCode: false })
+ .then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ })
+ })
+
+ describe('webhook endpoint', () => {
+ const payload = {
+ object: 'whatsapp_business_account',
+ entry: [{
+ id: 'waba-1',
+ changes: [{
+ field: 'messages',
+ value: {
+ metadata: { phone_number_id: 'pn-1' },
+ messages: [{ from: '919876543210', id: 'wamid.CYPRESS', timestamp: '1716000000', type: 'text', text: { body: 'hi' } }]
+ }
+ }]
+ }]
+ }
+
+ // The webhook is public by design - Meta authenticates with a signature, not a session.
+ it('rejects a delivery with no signature instead of asking for a login', () => {
+ cy.request({
+ method: 'POST',
+ url: '/webhooks/whatsapp/99999999',
+ body: payload,
+ failOnStatusCode: false
+ }).then(({ status, body }) => {
+ expect(status).to.not.eq(401)
+ expect(status).to.eq(404)
+ expect(body.error_type).to.eq('NotFoundException')
+ })
+ })
+
+ it('rejects a delivery aimed at a non-numeric inbox id', () => {
+ cy.request({
+ method: 'POST',
+ url: '/webhooks/whatsapp/not-an-id',
+ body: payload,
+ failOnStatusCode: false
+ }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ })
+ })
+
+ it('rejects a delivery aimed at an inbox that is not WhatsApp', () => {
+ cy.api('GET', '/api/v1/inboxes').then(({ body }) => {
+ const other = body.data.find((inbox) => inbox.channel !== 'whatsapp')
+ if (!other) return
+ cy.request({
+ method: 'POST',
+ url: `/webhooks/whatsapp/${other.id}`,
+ body: payload,
+ headers: { 'X-Hub-Signature-256': 'sha256=deadbeef' },
+ failOnStatusCode: false
+ }).then(({ status }) => {
+ expect(status).to.eq(404)
+ })
+ })
+ })
+
+ it('rejects a verification handshake with the wrong hub.mode', () => {
+ cy.request({
+ url: '/webhooks/whatsapp/99999999?hub.mode=unsubscribe&hub.verify_token=x&hub.challenge=y',
+ failOnStatusCode: false
+ }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.error_type).to.eq('InputException')
+ })
+ })
+
+ it('rejects a verification handshake for an unknown inbox', () => {
+ cy.request({
+ url: '/webhooks/whatsapp/99999999?hub.mode=subscribe&hub.verify_token=x&hub.challenge=y',
+ failOnStatusCode: false
+ }).then(({ status, body }) => {
+ expect(status).to.eq(404)
+ expect(body.error_type).to.eq('NotFoundException')
+ })
+ })
+ })
+})
diff --git a/frontend/cypress/e2e/integration/whatsapp/channel.cy.js b/frontend/cypress/e2e/integration/whatsapp/channel.cy.js
new file mode 100644
index 000000000..2a63c4a27
--- /dev/null
+++ b/frontend/cypress/e2e/integration/whatsapp/channel.cy.js
@@ -0,0 +1,487 @@
+// Needs the app running with whatsapp.api_url pointed at the mock Graph API (LIBREDESK_WHATSAPP__API_URL), else the suite stops.
+
+import { hoursAgo, inboundPayload, statusPayload, templateStatusPayload } from '../../../support/whatsapp'
+
+const stamp = `${Date.now()}`
+const inboxName = `WhatsApp E2E ${stamp}`
+const appSecret = `secret-${stamp}`
+const verifyToken = `verify-${stamp}`
+const phoneNumberID = `PN-${stamp}`
+const wabaID = `WABA-${stamp}`
+const waID = `9199${stamp.slice(-8)}`
+const staleWaID = `9188${stamp.slice(-8)}`
+const templateName = `e2e_order_update_${stamp}`
+
+describe('WhatsApp channel', () => {
+ let inboxID
+ let conversationUUID
+ let templateID
+
+ const inbound = (message) => inboundPayload({ wabaID, phoneNumberID, waID, message })
+ const post = (payload, options = {}) =>
+ cy.waPostWebhook(payload, { inboxID, secret: appSecret, ...options })
+ // cy.then defers the URL until conversationUUID has actually been assigned.
+ const messages = () => cy.then(() => cy.waMessages(conversationUUID))
+
+ before(() => {
+ cy.login()
+ cy.task('metaMock:reset')
+ })
+
+ beforeEach(() => cy.login())
+
+ it('creates the inbox once Meta accepts the credentials', () => {
+ cy.api('POST', '/api/v1/inboxes', {
+ name: inboxName,
+ channel: 'whatsapp',
+ enabled: true,
+ csat_enabled: true,
+ reopen_window_hours: 24,
+ config: {
+ phone_number_id: phoneNumberID,
+ waba_id: wabaID,
+ access_token: 'e2e-access-token',
+ app_secret: appSecret,
+ webhook_verify_token: verifyToken,
+ api_version: 'v25.0',
+ csat_template_language: 'en_US',
+ csat_template_body: 'How did we do?',
+ csat_template_button_text: 'Rate us'
+ }
+ }, { failOnStatusCode: false }).then(({ status, body }) => {
+ expect(
+ status,
+ 'creating the inbox needs whatsapp.api_url pointed at the mock (LIBREDESK_WHATSAPP__API_URL)'
+ ).to.eq(200)
+ inboxID = body.data.id
+ expect(body.data.webhook_url, 'callback url').to.contain(`/webhooks/whatsapp/${inboxID}`)
+ // Secrets never come back in the clear.
+ expect(body.data.config.access_token).to.not.eq('e2e-access-token')
+ })
+ })
+
+ it("answers Meta's verification handshake", () => {
+ cy.request({
+ url: `/webhooks/whatsapp/${inboxID}?hub.mode=subscribe&hub.verify_token=${verifyToken}&hub.challenge=CHAL123`
+ }).then(({ status, body }) => {
+ expect(status).to.eq(200)
+ expect(body).to.eq('CHAL123')
+ })
+ cy.request({
+ url: `/webhooks/whatsapp/${inboxID}?hub.mode=subscribe&hub.verify_token=wrong&hub.challenge=CHAL123`,
+ failOnStatusCode: false
+ })
+ .its('status')
+ .should('eq', 403)
+ })
+
+ it('rejects a delivery signed with the wrong secret', () => {
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.BADSIG.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'text',
+ text: { body: 'should not arrive' }
+ }),
+ { secret: 'not-the-app-secret' }
+ )
+ .its('status')
+ .should('eq', 403)
+ })
+
+ it('turns an inbound message into a conversation', () => {
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.IN1.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'text',
+ text: { body: 'my order is late' }
+ })
+ )
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the conversation to be created',
+ () =>
+ cy
+ .api('GET', '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50')
+ .then(({ body }) => body.data.results.find((c) => c.inbox_name === inboxName) ?? null),
+ (found) => Boolean(found)
+ ).then((conversation) => {
+ conversationUUID = conversation.uuid
+ expect(conversation.last_message).to.eq('my order is late')
+ expect(conversation.contact.first_name).to.eq('E2E')
+ })
+
+ cy.waPoll(
+ 'the inbound message to be stored',
+ () => messages().then((list) => list.find((m) => m.content?.includes('my order is late')) ?? null),
+ (message) => Boolean(message)
+ ).then((message) => {
+ expect(message.type).to.eq('incoming')
+ })
+ })
+
+ it('ignores a redelivery of the same message', () => {
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.IN1.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'text',
+ text: { body: 'my order is late' }
+ })
+ )
+ .its('status')
+ .should('eq', 200)
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting -- asserting a duplicate never lands needs a settle window
+ cy.wait(1500)
+ messages().then((list) => {
+ expect(list.filter((m) => m.content?.includes('my order is late'))).to.have.length(1)
+ })
+ })
+
+ it('downloads inbound media and attaches it', () => {
+ const mediaID = `MEDIA.${stamp}`
+ cy.task('metaMock:putMedia', { id: mediaID, body: 'file-contents', mime: 'text/plain' })
+
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.IN2.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'document',
+ document: { id: mediaID, mime_type: 'text/plain', filename: 'notes.txt', caption: 'the receipt' }
+ })
+ )
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the media message to be stored',
+ () => messages().then((list) => list.find((m) => m.content?.includes('the receipt')) ?? null),
+ (found) => Boolean(found)
+ ).then((message) => {
+ expect(message.content).to.contain('the receipt')
+ expect(message.attachments, 'attachments').to.have.length(1)
+ expect(message.attachments[0].name).to.eq('notes.txt')
+ })
+ })
+
+ it('sends a free-form reply inside the 24-hour window', () => {
+ cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
+ message: '
Checking on it now
',
+ sender_type: 'agent'
+ })
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the text send to reach Meta',
+ () => cy.waMetaCalls((r) => r.method === 'POST' && r.path.endsWith('/messages') && r.body?.type === 'text'),
+ (calls) => calls.length > 0
+ ).then((calls) => {
+ const sent = calls[calls.length - 1].body
+ expect(sent.to).to.eq(waID)
+ // HTML is flattened before it reaches WhatsApp.
+ expect(sent.text.body).to.eq('Checking on it now')
+ })
+
+ cy.waPoll(
+ 'the reply to be marked sent',
+ () => messages().then((list) => list.find((m) => m.content?.includes('Checking on it now')) ?? null),
+ (message) => message?.status === 'sent'
+ )
+ })
+
+ it('records delivery status from Meta and keeps the newest one', () => {
+ cy.waMetaCalls((r) => r.messageID && r.body?.type === 'text')
+ .then((calls) => calls[calls.length - 1].messageID)
+ .then((wamid) => {
+ const status = (value) => statusPayload({ wabaID, phoneNumberID, waID, id: wamid, status: value })
+ const providerStatus = (list) =>
+ list.find((m) => m.content?.includes('Checking on it now'))?.meta?.provider_status
+
+ post(status('delivered')).its('status').should('eq', 200)
+ cy.waPoll('delivered to be recorded', messages, (list) => providerStatus(list) === 'delivered')
+
+ post(status('read')).its('status').should('eq', 200)
+ cy.waPoll('read to be recorded', messages, (list) => providerStatus(list) === 'read')
+
+ // An out-of-order delivered must not walk the status back.
+ post(status('delivered')).its('status').should('eq', 200)
+ // eslint-disable-next-line cypress/no-unnecessary-waiting -- asserting the status does not move needs a settle window
+ cy.wait(1500)
+ messages().then((list) => expect(providerStatus(list)).to.eq('read'))
+ })
+ })
+
+ it('marks a send that Meta refuses as failed and retries it cleanly', () => {
+ cy.task('metaMock:failSend', 1)
+ cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
+ message: '
this one fails
',
+ sender_type: 'agent'
+ }).then(({ body }) => {
+ const uuid = body.data.uuid
+
+ cy.waPoll(
+ 'the send to be marked failed',
+ () => messages().then((list) => list.find((m) => m.uuid === uuid) ?? null),
+ (message) => message?.status === 'failed'
+ ).then((failed) => {
+ expect(failed.meta.provider_failure_reason).to.contain('24 hours')
+ })
+
+ cy.api('PUT', `/api/v1/conversations/${conversationUUID}/messages/${uuid}/retry`)
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the retry to go out',
+ () => messages().then((list) => list.find((m) => m.uuid === uuid) ?? null),
+ (message) => message?.status === 'sent'
+ ).then((sent) => {
+ // The previous attempt's failure must not linger, or later status webhooks are ignored.
+ expect(sent.meta.provider_failure_reason, 'stale failure reason').to.be.undefined
+ expect(sent.meta.provider_status, 'stale provider status').to.not.eq('failed')
+ })
+
+ cy.waMetaCalls((r) => r.messageID && r.body?.text?.body === 'this one fails').then((calls) => {
+ expect(calls, 'the retry reached Meta').to.have.length(1)
+ })
+ })
+ })
+
+ it('refuses to send a template Meta has not approved', () => {
+ cy.api('POST', '/api/v1/whatsapp/templates', {
+ inbox_id: inboxID,
+ name: templateName,
+ language: 'en_US',
+ category: 'UTILITY',
+ body_content: 'Hi {{name}}, order {{order_id}} is on its way.',
+ sample_values: { name: 'Ravi', order_id: 'A1' }
+ }).then(({ body }) => {
+ templateID = body.data.id
+ expect(body.data.status).to.eq('PENDING')
+ expect(body.data.meta_template_id, 'Meta accepted the submission').to.not.be.null
+ })
+
+ cy.then(() =>
+ cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
+ message: '',
+ sender_type: 'agent',
+ whatsapp_template_id: templateID,
+ whatsapp_template_params: { 'body:name': 'Ravi', 'body:order_id': 'A1' }
+ }, { failOnStatusCode: false }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.message).to.contain('not approved')
+ })
+ )
+ })
+
+ it('applies the approval webhook without storing a bogus reason', () => {
+ post(templateStatusPayload({ wabaID, name: templateName, event: 'APPROVED' }))
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the template to be approved',
+ () => cy.api('GET', `/api/v1/whatsapp/templates/${templateID}`).then(({ body }) => body.data),
+ (template) => template.status === 'APPROVED'
+ ).then((template) => {
+ // Meta sends reason "NONE" on approval, which must not surface as a rejection reason.
+ expect(template.rejection_reason).to.be.oneOf([null, ''])
+ })
+ })
+
+ it('blocks free-form once the reply window closes but still sends templates', () => {
+ // The window clock only moves forward, so a closed window needs a contact whose one message is old.
+ let staleUUID
+ cy.waPostWebhook(
+ inboundPayload({
+ wabaID,
+ phoneNumberID,
+ waID: staleWaID,
+ contactName: 'Stale Contact',
+ message: {
+ from: staleWaID,
+ id: `wamid.STALE.${stamp}`,
+ timestamp: hoursAgo(30),
+ type: 'text',
+ text: { body: 'this came in yesterday' }
+ }
+ }),
+ { inboxID, secret: appSecret }
+ )
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the stale conversation to be created',
+ () =>
+ cy
+ .api('GET', '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50')
+ .then(
+ ({ body }) =>
+ body.data.results.find(
+ (c) => c.inbox_name === inboxName && c.contact.first_name === 'Stale'
+ ) ?? null
+ ),
+ (found) => Boolean(found)
+ ).then((conversation) => {
+ staleUUID = conversation.uuid
+ })
+
+ cy.then(() =>
+ cy.api('POST', `/api/v1/conversations/${staleUUID}/messages`, {
+ message: '
outside the window
',
+ sender_type: 'agent'
+ }, { failOnStatusCode: false }).then(({ status, body }) => {
+ expect(status).to.eq(400)
+ expect(body.message).to.contain('24-hour reply window')
+ })
+ )
+
+ cy.then(() =>
+ cy.api('POST', `/api/v1/conversations/${staleUUID}/messages`, {
+ message: '',
+ sender_type: 'agent',
+ whatsapp_template_id: templateID,
+ whatsapp_template_params: { 'body:name': 'Stale', 'body:order_id': 'B2' }
+ })
+ .its('status')
+ .should('eq', 200)
+ )
+
+ cy.waPoll(
+ 'the template send to reach Meta',
+ () => cy.waMetaCalls((r) => r.body?.template?.name === templateName),
+ (calls) => calls.length > 0
+ ).then((calls) => {
+ expect(JSON.stringify(calls[calls.length - 1].body.template.components)).to.contain('Stale')
+ })
+
+ // The timeline shows the filled-in copy, not the placeholders.
+ cy.waPoll(
+ 'the rendered template in the timeline',
+ () => cy.waMessages(staleUUID),
+ (list) => list.some((m) => m.content?.includes('order B2 is on its way'))
+ )
+ })
+
+ it('rejects a template that does not belong to the inbox', () => {
+ cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
+ message: '',
+ sender_type: 'agent',
+ whatsapp_template_id: 99999999
+ }, { failOnStatusCode: false })
+ .its('status')
+ .should('be.oneOf', [400, 404])
+ })
+
+ it('provisions the CSAT template and sends it when the conversation is resolved', () => {
+ let csatName
+ cy.then(() => {
+ csatName = `libredesk_csat_${inboxID}`
+ })
+
+ cy.waPoll(
+ 'the CSAT template to be provisioned',
+ () =>
+ cy
+ .api('GET', `/api/v1/whatsapp/templates?inbox_id=${inboxID}`)
+ .then(({ body }) => body.data.find((t) => t.name === csatName) ?? null),
+ (template) => Boolean(template)
+ ).then((csat) => {
+ expect(csat.body_content).to.eq('How did we do?')
+ expect(JSON.stringify(csat.buttons)).to.contain('Rate us')
+ })
+
+ cy.then(() => post(templateStatusPayload({ wabaID, name: csatName, event: 'APPROVED' })))
+ cy.waPoll(
+ 'the CSAT template to be approved',
+ () =>
+ cy
+ .api('GET', `/api/v1/whatsapp/templates?inbox_id=${inboxID}`)
+ .then(({ body }) => body.data.find((t) => t.name === csatName) ?? null),
+ (template) => template?.status === 'APPROVED'
+ )
+
+ cy.api('PUT', `/api/v1/conversations/${conversationUUID}/status`, { status: 'Resolved' })
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the survey to be sent',
+ () => cy.waMetaCalls((r) => r.body?.template?.name === csatName),
+ (calls) => calls.length > 0
+ ).then((calls) => {
+ // The survey link carries the CSAT response id as the button parameter.
+ expect(JSON.stringify(calls[calls.length - 1].body.template.components)).to.contain('sub_type')
+ })
+ })
+
+ it('reopens the resolved conversation when the contact writes back', () => {
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.REOPEN.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'text',
+ text: { body: 'still broken' }
+ })
+ )
+ .its('status')
+ .should('eq', 200)
+
+ cy.waPoll(
+ 'the conversation to reopen',
+ () => cy.api('GET', `/api/v1/conversations/${conversationUUID}`).then(({ body }) => body.data),
+ (conversation) => conversation.status === 'Open'
+ )
+ messages().then((list) => {
+ expect(list.find((m) => m.content?.includes('still broken')), 'reopening message').to.exist
+ })
+ })
+
+ it('drops inbound messages while the inbox is disabled', () => {
+ cy.api('PUT', `/api/v1/inboxes/${inboxID}/toggle`).its('status').should('eq', 200)
+
+ post(
+ inbound({
+ from: waID,
+ id: `wamid.OFF.${stamp}`,
+ timestamp: hoursAgo(0),
+ type: 'text',
+ text: { body: 'nobody is listening' }
+ })
+ )
+ .its('status')
+ .should('eq', 200)
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting -- asserting the message is dropped needs a settle window
+ cy.wait(2000)
+ messages().then((list) => {
+ expect(list.find((m) => m.content?.includes('nobody is listening'))).to.be.undefined
+ })
+
+ cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
+ message: '
while disabled
',
+ sender_type: 'agent'
+ }, { failOnStatusCode: false })
+ .its('status')
+ .should('eq', 400)
+
+ cy.api('PUT', `/api/v1/inboxes/${inboxID}/toggle`).its('status').should('eq', 200)
+ })
+
+ after(() => {
+ if (!inboxID) return
+ cy.login()
+ cy.api('DELETE', `/api/v1/inboxes/${inboxID}`, null, { failOnStatusCode: false })
+ })
+})
diff --git a/frontend/cypress/e2e/ui/whatsappInboxForm.cy.js b/frontend/cypress/e2e/ui/whatsappInboxForm.cy.js
new file mode 100644
index 000000000..b15316b5c
--- /dev/null
+++ b/frontend/cypress/e2e/ui/whatsappInboxForm.cy.js
@@ -0,0 +1,97 @@
+// Saving a WhatsApp inbox makes the backend ask Meta to verify the credentials, so this spec never creates one.
+
+const stamp = Date.now()
+const inboxName = `Cypress WhatsApp ${stamp}`
+const newPath = '/admin/inboxes/new'
+
+const openNewForm = () => {
+ cy.visit(newPath)
+ cy.contains('Create a WhatsApp inbox').click()
+}
+
+describe('WhatsApp inbox form', () => {
+ beforeEach(() => {
+ cy.viewport(1280, 800)
+ cy.login()
+ })
+
+ it('shows every credential and webhook field', () => {
+ openNewForm()
+
+ cy.get('input[name="name"]').should('exist')
+ cy.get('input[name="reopen_window_hours"]').should('exist')
+ cy.get('input[name="config.phone_number_id"]').should('exist')
+ cy.get('input[name="config.waba_id"]').should('exist')
+ cy.get('input[name="config.access_token"]').should('exist')
+ cy.get('input[name="config.app_secret"]').should('exist')
+ cy.get('input[name="config.webhook_verify_token"]').should('exist')
+ cy.get('input[name="config.api_version"]').should('have.value', 'v25.0')
+
+ // The callback URL only exists once the inbox has an id.
+ cy.contains('Save the inbox to generate the webhook URL.').scrollIntoView()
+ cy.contains('Save the inbox to generate the webhook URL.').should('be.visible')
+ })
+
+ it('keeps the CSAT template fields hidden until CSAT surveys are on', () => {
+ openNewForm()
+
+ cy.get('textarea[name="config.csat_template_body"]').should('not.be.visible')
+ cy.contains('CSAT Surveys').parent().find('button[role="switch"]').click()
+ cy.get('textarea[name="config.csat_template_body"]')
+ .should('be.visible')
+ .and('not.have.value', '')
+ cy.get('input[name="config.csat_template_button_text"]').should('have.value', 'Rate us')
+ })
+
+ it('rejects a submit with no name and no credentials', () => {
+ cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
+
+ openNewForm()
+ cy.get('button[type="submit"]').click()
+
+ cy.get('input[name="name"]').scrollIntoView()
+ cy.contains(/required/i).should('be.visible')
+ cy.get('@createInbox.all').should('have.length', 0)
+ cy.location('pathname').should('eq', newPath)
+ })
+
+ it('rejects a submit that is missing only the app secret', () => {
+ cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
+
+ openNewForm()
+ cy.get('input[name="name"]').type(inboxName)
+ cy.get('input[name="config.phone_number_id"]').type(`pn-${stamp}`)
+ cy.get('input[name="config.waba_id"]').type(`waba-${stamp}`)
+ cy.get('input[name="config.access_token"]').type('dummy-access-token')
+ cy.get('input[name="config.webhook_verify_token"]').type(`verify-${stamp}`)
+
+ cy.get('button[type="submit"]').click()
+
+ cy.get('input[name="config.app_secret"]').scrollIntoView()
+ cy.contains(/required/i).should('be.visible')
+ cy.get('@createInbox.all').should('have.length', 0)
+ })
+
+ it('surfaces the error when Meta rejects the credentials', () => {
+ cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
+ // Deterministic either way: the stand-in Graph API is told to refuse, and real Meta refuses anyway.
+ cy.task('metaMock:failValidate', true)
+
+ openNewForm()
+ cy.get('input[name="name"]').type(inboxName)
+ cy.get('input[name="config.phone_number_id"]').type(`pn-${stamp}`)
+ cy.get('input[name="config.waba_id"]').type(`waba-${stamp}`)
+ cy.get('input[name="config.access_token"]').type('dummy-access-token')
+ cy.get('input[name="config.app_secret"]').type('dummy-app-secret')
+ cy.get('input[name="config.webhook_verify_token"]').type(`verify-${stamp}`)
+
+ cy.get('button[type="submit"]').click()
+
+ cy.wait('@createInbox', { timeout: 90000 }).its('response.statusCode').should('eq', 400)
+ cy.location('pathname').should('eq', newPath)
+ cy.api('GET', '/api/v1/inboxes').then(({ body }) => {
+ expect(body.data.filter((inbox) => inbox.name === inboxName)).to.have.length(0)
+ })
+ cy.task('metaMock:failValidate', false)
+ })
+})
diff --git a/frontend/cypress/support/e2e.js b/frontend/cypress/support/e2e.js
index 314f6883c..5ade811dc 100644
--- a/frontend/cypress/support/e2e.js
+++ b/frontend/cypress/support/e2e.js
@@ -16,6 +16,7 @@
// Import commands.js using ES2015 syntax:
import './commands'
import './livechat'
+import './whatsapp'
// Alternatively you can use CommonJS syntax:
// require('./commands')
diff --git a/frontend/cypress/support/metaMock.mjs b/frontend/cypress/support/metaMock.mjs
new file mode 100644
index 000000000..2c72b3aeb
--- /dev/null
+++ b/frontend/cypress/support/metaMock.mjs
@@ -0,0 +1,224 @@
+/* eslint-env node */
+// Stand-in for Meta's Graph API, so the WhatsApp channel can be driven without a real Business Account.
+import http from 'node:http'
+import crypto from 'node:crypto'
+
+const DEFAULT_PORT = 9099
+
+const state = {
+ requests: [],
+ templates: new Map(),
+ media: new Map(),
+ failSend: 0,
+ failValidate: false,
+ counter: 0
+}
+
+const json = (res, code, body) => {
+ res.writeHead(code, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(body))
+}
+
+const metaError = (res, code, message, errorCode = 100) =>
+ json(res, code, {
+ error: { message, type: 'OAuthException', code: errorCode, error_user_msg: message, fbtrace_id: 'MOCK' }
+ })
+
+const nextID = () => ++state.counter
+
+const readBody = (req) =>
+ new Promise((resolve) => {
+ const chunks = []
+ req.on('data', (chunk) => chunks.push(chunk))
+ req.on('end', () => resolve(Buffer.concat(chunks)))
+ })
+
+const parseUpload = (raw, contentType) => {
+ const boundary = (contentType.match(/boundary=(.+)$/) || [])[1]
+ if (!boundary) return {}
+ const text = raw.toString('latin1')
+ const part = text.split('--' + boundary).find((p) => p.includes('name="file"')) || ''
+ return {
+ filename: (part.match(/filename="([^"]*)"/) || [])[1] || '',
+ contentType: (part.match(/Content-Type:\s*([^\r\n]+)/) || [])[1] || ''
+ }
+}
+
+const handle = async (req, res) => {
+ const url = new URL(req.url, 'http://mock')
+ const path = url.pathname
+ const raw = await readBody(req)
+ const contentType = req.headers['content-type'] || ''
+ const isMultipart = contentType.startsWith('multipart/form-data')
+
+ let body = null
+ if (raw.length && !isMultipart) {
+ try {
+ body = JSON.parse(raw.toString())
+ } catch {
+ body = raw.toString()
+ }
+ }
+
+ if (path.startsWith('/__ctl/')) {
+ const action = path.replace('/__ctl/', '')
+ if (action === 'reset') {
+ state.requests = []
+ state.templates.clear()
+ state.media.clear()
+ state.failSend = 0
+ state.failValidate = false
+ return json(res, 200, { ok: true })
+ }
+ if (action === 'requests') {
+ return json(res, 200, state.requests)
+ }
+ if (action === 'fail') {
+ if (url.searchParams.has('send')) state.failSend = Number(url.searchParams.get('send'))
+ if (url.searchParams.has('validate')) state.failValidate = url.searchParams.get('validate') === '1'
+ return json(res, 200, { ok: true })
+ }
+ if (action === 'media') {
+ const id = url.searchParams.get('id') || 'MEDIA' + nextID()
+ state.media.set(id, { body: raw, mime: url.searchParams.get('mime') || 'application/octet-stream' })
+ return json(res, 200, { id })
+ }
+ return json(res, 404, { error: 'unknown control endpoint' })
+ }
+
+ state.requests.push({
+ method: req.method,
+ path,
+ query: url.search.replace(/^\?/, ''),
+ auth: req.headers.authorization || '',
+ body: isMultipart ? parseUpload(raw, contentType) : body
+ })
+
+ if (path.startsWith('/media/')) {
+ const entry = state.media.get(path.replace('/media/', ''))
+ if (!entry) return metaError(res, 404, 'media not found')
+ res.writeHead(200, { 'Content-Type': entry.mime })
+ return res.end(entry.body)
+ }
+
+ const parts = path.split('/').filter(Boolean).slice(1)
+
+ if (parts.length === 2 && parts[1] === 'messages' && req.method === 'POST') {
+ if (body?.status === 'read') return json(res, 200, { success: true })
+ if (state.failSend > 0) {
+ state.failSend--
+ return metaError(res, 400, 'Message failed to send because more than 24 hours have passed since the customer last replied to this number.', 131047)
+ }
+ const messageID = 'wamid.MOCK' + nextID()
+ // The app never exposes the Meta id over its API, so the recorded call is how a test learns it.
+ state.requests[state.requests.length - 1].messageID = messageID
+ return json(res, 200, {
+ messaging_product: 'whatsapp',
+ contacts: [{ input: body?.to, wa_id: body?.to }],
+ messages: [{ id: messageID, message_status: 'accepted' }]
+ })
+ }
+
+ if (parts.length === 2 && parts[1] === 'media' && req.method === 'POST') {
+ const id = 'MEDIAUP' + nextID()
+ state.media.set(id, { body: raw, mime: 'application/octet-stream' })
+ return json(res, 200, { id })
+ }
+
+ if (parts.length === 2 && parts[1] === 'phone_numbers') {
+ if (state.failValidate) return metaError(res, 400, 'Object with ID does not exist', 803)
+ return json(res, 200, { data: [{ id: 'PHONE1', display_phone_number: '+1 555 000 1111' }] })
+ }
+
+ if (parts.length === 2 && parts[1] === 'subscribed_apps' && req.method === 'POST') {
+ return json(res, 200, { success: true })
+ }
+
+ if (parts.length === 2 && parts[1] === 'message_templates') {
+ if (req.method === 'GET') {
+ return json(res, 200, { data: [...state.templates.values()], paging: {} })
+ }
+ if (req.method === 'POST') {
+ const id = String(1000000000000000 + nextID())
+ state.templates.set(id, { ...body, id, status: 'PENDING' })
+ return json(res, 200, { id, status: 'PENDING', category: body?.category })
+ }
+ if (req.method === 'DELETE') {
+ const name = url.searchParams.get('name')
+ for (const [id, tmpl] of state.templates) {
+ if (tmpl.name === name) state.templates.delete(id)
+ }
+ return json(res, 200, { success: true })
+ }
+ }
+
+ // A single segment is either media info, a template edit, or the phone number check.
+ if (parts.length === 1) {
+ const id = parts[0]
+ if (req.method === 'POST') {
+ const tmpl = state.templates.get(id)
+ if (tmpl) state.templates.set(id, { ...tmpl, ...body, status: 'PENDING' })
+ return json(res, 200, { success: true })
+ }
+ const entry = state.media.get(id)
+ if (entry) {
+ return json(res, 200, {
+ url: `http://127.0.0.1:${server.address().port}/media/${id}`,
+ mime_type: entry.mime,
+ file_size: entry.body.length,
+ id,
+ messaging_product: 'whatsapp'
+ })
+ }
+ if (state.failValidate) return metaError(res, 400, 'Object with ID does not exist', 803)
+ return json(res, 200, { id, display_phone_number: '+1 555 000 1111', verified_name: 'Mock Co' })
+ }
+
+ return metaError(res, 404, 'unsupported request ' + path)
+}
+
+const server = http.createServer((req, res) => {
+ handle(req, res).catch(() => metaError(res, 500, 'mock failure'))
+})
+
+export const start = (port = DEFAULT_PORT) =>
+ new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(port, '127.0.0.1', () => resolve(server))
+ })
+
+export const stop = () => new Promise((resolve) => server.close(resolve))
+
+export const control = {
+ reset() {
+ state.requests = []
+ state.templates.clear()
+ state.media.clear()
+ state.failSend = 0
+ state.failValidate = false
+ return null
+ },
+ requests: () => state.requests,
+ failSend(n) {
+ state.failSend = n
+ return null
+ },
+ failValidate(on) {
+ state.failValidate = !!on
+ return null
+ },
+ putMedia({ id, body, mime }) {
+ state.media.set(id, { body: Buffer.from(body), mime })
+ return null
+ },
+ sign({ body, secret }) {
+ return 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex')
+ }
+}
+
+// Allow running standalone: `node cypress/support/metaMock.mjs`
+if (process.argv[1] && process.argv[1].endsWith('metaMock.mjs')) {
+ start(Number(process.env.META_MOCK_PORT) || DEFAULT_PORT).then((s) =>
+ console.log('meta mock listening on', s.address())
+ )
+}
diff --git a/frontend/cypress/support/whatsapp.js b/frontend/cypress/support/whatsapp.js
new file mode 100644
index 000000000..74afdbd6b
--- /dev/null
+++ b/frontend/cypress/support/whatsapp.js
@@ -0,0 +1,97 @@
+export const hoursAgo = (hours) => String(Math.floor(Date.now() / 1000) - hours * 3600)
+
+export const inboundPayload = ({ wabaID, phoneNumberID, waID, contactName = 'E2E Contact', message }) => ({
+ object: 'whatsapp_business_account',
+ entry: [
+ {
+ id: wabaID,
+ changes: [
+ {
+ field: 'messages',
+ value: {
+ messaging_product: 'whatsapp',
+ metadata: { display_phone_number: '15550001111', phone_number_id: phoneNumberID },
+ contacts: [{ profile: { name: contactName }, wa_id: waID }],
+ messages: [message]
+ }
+ }
+ ]
+ }
+ ]
+})
+
+export const statusPayload = ({ wabaID, phoneNumberID, waID, id, status, errors }) => ({
+ object: 'whatsapp_business_account',
+ entry: [
+ {
+ id: wabaID,
+ changes: [
+ {
+ field: 'messages',
+ value: {
+ metadata: { phone_number_id: phoneNumberID },
+ statuses: [
+ { id, status, timestamp: hoursAgo(0), recipient_id: waID, ...(errors ? { errors } : {}) }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+})
+
+export const templateStatusPayload = ({ wabaID, name, event, reason = 'NONE' }) => ({
+ object: 'whatsapp_business_account',
+ entry: [
+ {
+ id: wabaID,
+ changes: [
+ {
+ field: 'message_template_status_update',
+ value: {
+ event,
+ message_template_name: name,
+ message_template_language: 'en_US',
+ reason
+ }
+ }
+ ]
+ }
+ ]
+})
+
+// Meta signs every delivery with the app secret, so an unsigned or wrongly signed body is refused.
+Cypress.Commands.add('waPostWebhook', (payload, { inboxID, secret, failOnStatusCode = false } = {}) => {
+ const body = JSON.stringify(payload)
+ return cy.task('metaMock:sign', { body, secret }).then((signature) =>
+ cy.request({
+ method: 'POST',
+ url: `/webhooks/whatsapp/${inboxID}`,
+ headers: { 'Content-Type': 'application/json', 'X-Hub-Signature-256': signature },
+ body,
+ failOnStatusCode
+ })
+ )
+})
+
+// fetch must yield null for "not there yet". On undefined Cypress passes the previous subject through, ending the poll on the wrong value.
+Cypress.Commands.add('waPoll', (label, fetch, check, tries = 30) => {
+ const attempt = (left) =>
+ fetch().then((result) => {
+ if (check(result)) return cy.wrap(result, { log: false })
+ if (left <= 0) throw new Error(`timed out waiting for ${label}`)
+ // eslint-disable-next-line cypress/no-unnecessary-waiting -- the pause between polls is the retry
+ return cy.wait(500, { log: false }).then(() => attempt(left - 1))
+ })
+ return attempt(tries)
+})
+
+Cypress.Commands.add('waMetaCalls', (predicate) =>
+ cy.task('metaMock:requests').then((requests) => requests.filter(predicate))
+)
+
+Cypress.Commands.add('waMessages', (conversationUUID) =>
+ cy
+ .api('GET', `/api/v1/conversations/${conversationUUID}/messages`)
+ .then(({ body }) => body.data.results)
+)
diff --git a/frontend/shared-ui/constants/countries.js b/frontend/shared-ui/constants/countries.js
index f26e03d0a..233e61ff8 100644
--- a/frontend/shared-ui/constants/countries.js
+++ b/frontend/shared-ui/constants/countries.js
@@ -1,243 +1,4 @@
-const countries = [
- { calling_code: '+93', name: 'Afghanistan', emoji: '🇦🇫', iso_2: 'AF' },
- { calling_code: '+355', name: 'Albania', emoji: '🇦🇱', iso_2: 'AL' },
- { calling_code: '+213', name: 'Algeria', emoji: '🇩🇿', iso_2: 'DZ' },
- { calling_code: '+1-684', name: 'American Samoa', emoji: '🇦🇸', iso_2: 'AS' },
- { calling_code: '+376', name: 'Andorra', emoji: '🇦🇩', iso_2: 'AD' },
- { calling_code: '+244', name: 'Angola', emoji: '🇦🇴', iso_2: 'AO' },
- { calling_code: '+1-264', name: 'Anguilla', emoji: '🇦🇮', iso_2: 'AI' },
- { calling_code: '+1-268', name: 'Antigua and Barbuda', emoji: '🇦🇬', iso_2: 'AG' },
- { calling_code: '+54', name: 'Argentina', emoji: '🇦🇷', iso_2: 'AR' },
- { calling_code: '+374', name: 'Armenia', emoji: '🇦🇲', iso_2: 'AM' },
- { calling_code: '+297', name: 'Aruba', emoji: '🇦🇼', iso_2: 'AW' },
- { calling_code: '+61', name: 'Australia', emoji: '🇦🇺', iso_2: 'AU' },
- { calling_code: '+43', name: 'Austria', emoji: '🇦🇹', iso_2: 'AT' },
- { calling_code: '+994', name: 'Azerbaijan', emoji: '🇦🇿', iso_2: 'AZ' },
- { calling_code: '+1-242', name: 'Bahamas', emoji: '🇧🇸', iso_2: 'BS' },
- { calling_code: '+973', name: 'Bahrain', emoji: '🇧ðŸ‡', iso_2: 'BH' },
- { calling_code: '+880', name: 'Bangladesh', emoji: '🇧🇩', iso_2: 'BD' },
- { calling_code: '+1-246', name: 'Barbados', emoji: '🇧🇧', iso_2: 'BB' },
- { calling_code: '+375', name: 'Belarus', emoji: '🇧🇾', iso_2: 'BY' },
- { calling_code: '+32', name: 'Belgium', emoji: '🇧🇪', iso_2: 'BE' },
- { calling_code: '+501', name: 'Belize', emoji: '🇧🇿', iso_2: 'BZ' },
- { calling_code: '+229', name: 'Benin', emoji: '🇧🇯', iso_2: 'BJ' },
- { calling_code: '+1-441', name: 'Bermuda', emoji: '🇧🇲', iso_2: 'BM' },
- { calling_code: '+975', name: 'Bhutan', emoji: '🇧🇹', iso_2: 'BT' },
- { calling_code: '+591', name: 'Bolivia', emoji: '🇧🇴', iso_2: 'BO' },
- { calling_code: '+387', name: 'Bosnia and Herzegovina', emoji: '🇧🇦', iso_2: 'BA' },
- { calling_code: '+267', name: 'Botswana', emoji: '🇧🇼', iso_2: 'BW' },
- { calling_code: '+55', name: 'Brazil', emoji: '🇧🇷', iso_2: 'BR' },
- { calling_code: '+246', name: 'British Indian Ocean Territory', emoji: '🇮🇴', iso_2: 'IO' },
- { calling_code: '+673', name: 'Brunei', emoji: '🇧🇳', iso_2: 'BN' },
- { calling_code: '+359', name: 'Bulgaria', emoji: '🇧🇬', iso_2: 'BG' },
- { calling_code: '+226', name: 'Burkina Faso', emoji: '🇧🇫', iso_2: 'BF' },
- { calling_code: '+257', name: 'Burundi', emoji: '🇧🇮', iso_2: 'BI' },
- { calling_code: '+855', name: 'Cambodia', emoji: '🇰ðŸ‡', iso_2: 'KH' },
- { calling_code: '+237', name: 'Cameroon', emoji: '🇨🇲', iso_2: 'CM' },
- { calling_code: '+1', name: 'Canada', emoji: '🇨🇦', iso_2: 'CA' },
- { calling_code: '+238', name: 'Cape Verde', emoji: '🇨🇻', iso_2: 'CV' },
- { calling_code: '+1-345', name: 'Cayman Islands', emoji: '🇰🇾', iso_2: 'KY' },
- { calling_code: '+236', name: 'Central African Republic', emoji: '🇨🇫', iso_2: 'CF' },
- { calling_code: '+235', name: 'Chad', emoji: '🇹🇩', iso_2: 'TD' },
- { calling_code: '+56', name: 'Chile', emoji: '🇨🇱', iso_2: 'CL' },
- { calling_code: '+86', name: 'China', emoji: '🇨🇳', iso_2: 'CN' },
- { calling_code: '+61', name: 'Christmas Island', emoji: '🇨🇽', iso_2: 'CX' },
- { calling_code: '+61', name: 'Cocos (Keeling) Islands', emoji: '🇨🇨', iso_2: 'CC' },
- { calling_code: '+57', name: 'Colombia', emoji: '🇨🇴', iso_2: 'CO' },
- { calling_code: '+269', name: 'Comoros', emoji: '🇰🇲', iso_2: 'KM' },
- { calling_code: '+242', name: 'Congo', emoji: '🇨🇬', iso_2: 'CG' },
- { calling_code: '+243', name: 'Congo, Democratic Republic of the', emoji: '🇨🇩', iso_2: 'CD' },
- { calling_code: '+682', name: 'Cook Islands', emoji: '🇨🇰', iso_2: 'CK' },
- { calling_code: '+506', name: 'Costa Rica', emoji: '🇨🇷', iso_2: 'CR' },
- { calling_code: '+225', name: "Côte d'Ivoire", emoji: '🇨🇮', iso_2: 'CI' },
- { calling_code: '+385', name: 'Croatia', emoji: 'ðŸ‡ðŸ‡·', iso_2: 'HR' },
- { calling_code: '+53', name: 'Cuba', emoji: '🇨🇺', iso_2: 'CU' },
- { calling_code: '+599', name: 'Curaçao', emoji: '🇨🇼', iso_2: 'CW' },
- { calling_code: '+357', name: 'Cyprus', emoji: '🇨🇾', iso_2: 'CY' },
- { calling_code: '+420', name: 'Czech Republic', emoji: '🇨🇿', iso_2: 'CZ' },
- { calling_code: '+45', name: 'Denmark', emoji: '🇩🇰', iso_2: 'DK' },
- { calling_code: '+253', name: 'Djibouti', emoji: '🇩🇯', iso_2: 'DJ' },
- { calling_code: '+1-767', name: 'Dominica', emoji: '🇩🇲', iso_2: 'DM' },
- { calling_code: '+1-809', name: 'Dominican Republic', emoji: '🇩🇴', iso_2: 'DO' },
- { calling_code: '+593', name: 'Ecuador', emoji: '🇪🇨', iso_2: 'EC' },
- { calling_code: '+20', name: 'Egypt', emoji: '🇪🇬', iso_2: 'EG' },
- { calling_code: '+503', name: 'El Salvador', emoji: '🇸🇻', iso_2: 'SV' },
- { calling_code: '+240', name: 'Equatorial Guinea', emoji: '🇬🇶', iso_2: 'GQ' },
- { calling_code: '+291', name: 'Eritrea', emoji: '🇪🇷', iso_2: 'ER' },
- { calling_code: '+372', name: 'Estonia', emoji: '🇪🇪', iso_2: 'EE' },
- { calling_code: '+268', name: 'Eswatini', emoji: '🇸🇿', iso_2: 'SZ' },
- { calling_code: '+251', name: 'Ethiopia', emoji: '🇪🇹', iso_2: 'ET' },
- { calling_code: '+500', name: 'Falkland Islands', emoji: '🇫🇰', iso_2: 'FK' },
- { calling_code: '+298', name: 'Faroe Islands', emoji: '🇫🇴', iso_2: 'FO' },
- { calling_code: '+679', name: 'Fiji', emoji: '🇫🇯', iso_2: 'FJ' },
- { calling_code: '+358', name: 'Finland', emoji: '🇫🇮', iso_2: 'FI' },
- { calling_code: '+33', name: 'France', emoji: '🇫🇷', iso_2: 'FR' },
- { calling_code: '+594', name: 'French Guiana', emoji: '🇬🇫', iso_2: 'GF' },
- { calling_code: '+689', name: 'French Polynesia', emoji: '🇵🇫', iso_2: 'PF' },
- { calling_code: '+241', name: 'Gabon', emoji: '🇬🇦', iso_2: 'GA' },
- { calling_code: '+220', name: 'Gambia', emoji: '🇬🇲', iso_2: 'GM' },
- { calling_code: '+995', name: 'Georgia', emoji: '🇬🇪', iso_2: 'GE' },
- { calling_code: '+49', name: 'Germany', emoji: '🇩🇪', iso_2: 'DE' },
- { calling_code: '+233', name: 'Ghana', emoji: '🇬ðŸ‡', iso_2: 'GH' },
- { calling_code: '+350', name: 'Gibraltar', emoji: '🇬🇮', iso_2: 'GI' },
- { calling_code: '+30', name: 'Greece', emoji: '🇬🇷', iso_2: 'GR' },
- { calling_code: '+299', name: 'Greenland', emoji: '🇬🇱', iso_2: 'GL' },
- { calling_code: '+1-473', name: 'Grenada', emoji: '🇬🇩', iso_2: 'GD' },
- { calling_code: '+590', name: 'Guadeloupe', emoji: '🇬🇵', iso_2: 'GP' },
- { calling_code: '+1-671', name: 'Guam', emoji: '🇬🇺', iso_2: 'GU' },
- { calling_code: '+502', name: 'Guatemala', emoji: '🇬🇹', iso_2: 'GT' },
- { calling_code: '+44-1481', name: 'Guernsey', emoji: '🇬🇬', iso_2: 'GG' },
- { calling_code: '+224', name: 'Guinea', emoji: '🇬🇳', iso_2: 'GN' },
- { calling_code: '+245', name: 'Guinea-Bissau', emoji: '🇬🇼', iso_2: 'GW' },
- { calling_code: '+592', name: 'Guyana', emoji: '🇬🇾', iso_2: 'GY' },
- { calling_code: '+509', name: 'Haiti', emoji: 'ðŸ‡ðŸ‡¹', iso_2: 'HT' },
- { calling_code: '+379', name: 'Vatican City', emoji: '🇻🇦', iso_2: 'VA' },
- { calling_code: '+504', name: 'Honduras', emoji: 'ðŸ‡ðŸ‡³', iso_2: 'HN' },
- { calling_code: '+852', name: 'Hong Kong', emoji: 'ðŸ‡ðŸ‡°', iso_2: 'HK' },
- { calling_code: '+36', name: 'Hungary', emoji: 'ðŸ‡ðŸ‡º', iso_2: 'HU' },
- { calling_code: '+354', name: 'Iceland', emoji: '🇮🇸', iso_2: 'IS' },
- { calling_code: '+91', name: 'India', emoji: '🇮🇳', iso_2: 'IN' },
- { calling_code: '+62', name: 'Indonesia', emoji: '🇮🇩', iso_2: 'ID' },
- { calling_code: '+98', name: 'Iran', emoji: '🇮🇷', iso_2: 'IR' },
- { calling_code: '+964', name: 'Iraq', emoji: '🇮🇶', iso_2: 'IQ' },
- { calling_code: '+353', name: 'Ireland', emoji: '🇮🇪', iso_2: 'IE' },
- { calling_code: '+44-1624', name: 'Isle of Man', emoji: '🇮🇲', iso_2: 'IM' },
- { calling_code: '+972', name: 'Israel', emoji: '🇮🇱', iso_2: 'IL' },
- { calling_code: '+39', name: 'Italy', emoji: '🇮🇹', iso_2: 'IT' },
- { calling_code: '+1-876', name: 'Jamaica', emoji: '🇯🇲', iso_2: 'JM' },
- { calling_code: '+81', name: 'Japan', emoji: '🇯🇵', iso_2: 'JP' },
- { calling_code: '+44-1534', name: 'Jersey', emoji: '🇯🇪', iso_2: 'JE' },
- { calling_code: '+962', name: 'Jordan', emoji: '🇯🇴', iso_2: 'JO' },
- { calling_code: '+7', name: 'Kazakhstan', emoji: '🇰🇿', iso_2: 'KZ' },
- { calling_code: '+254', name: 'Kenya', emoji: '🇰🇪', iso_2: 'KE' },
- { calling_code: '+686', name: 'Kiribati', emoji: '🇰🇮', iso_2: 'KI' },
- { calling_code: '+383', name: 'Kosovo', emoji: '🇽🇰', iso_2: 'XK' },
- { calling_code: '+965', name: 'Kuwait', emoji: '🇰🇼', iso_2: 'KW' },
- { calling_code: '+996', name: 'Kyrgyzstan', emoji: '🇰🇬', iso_2: 'KG' },
- { calling_code: '+856', name: 'Laos', emoji: '🇱🇦', iso_2: 'LA' },
- { calling_code: '+371', name: 'Latvia', emoji: '🇱🇻', iso_2: 'LV' },
- { calling_code: '+961', name: 'Lebanon', emoji: '🇱🇧', iso_2: 'LB' },
- { calling_code: '+266', name: 'Lesotho', emoji: '🇱🇸', iso_2: 'LS' },
- { calling_code: '+231', name: 'Liberia', emoji: '🇱🇷', iso_2: 'LR' },
- { calling_code: '+218', name: 'Libya', emoji: '🇱🇾', iso_2: 'LY' },
- { calling_code: '+423', name: 'Liechtenstein', emoji: '🇱🇮', iso_2: 'LI' },
- { calling_code: '+370', name: 'Lithuania', emoji: '🇱🇹', iso_2: 'LT' },
- { calling_code: '+352', name: 'Luxembourg', emoji: '🇱🇺', iso_2: 'LU' },
- { calling_code: '+853', name: 'Macao', emoji: '🇲🇴', iso_2: 'MO' },
- { calling_code: '+389', name: 'North Macedonia', emoji: '🇲🇰', iso_2: 'MK' },
- { calling_code: '+261', name: 'Madagascar', emoji: '🇲🇬', iso_2: 'MG' },
- { calling_code: '+265', name: 'Malawi', emoji: '🇲🇼', iso_2: 'MW' },
- { calling_code: '+60', name: 'Malaysia', emoji: '🇲🇾', iso_2: 'MY' },
- { calling_code: '+960', name: 'Maldives', emoji: '🇲🇻', iso_2: 'MV' },
- { calling_code: '+223', name: 'Mali', emoji: '🇲🇱', iso_2: 'ML' },
- { calling_code: '+356', name: 'Malta', emoji: '🇲🇹', iso_2: 'MT' },
- { calling_code: '+692', name: 'Marshall Islands', emoji: '🇲ðŸ‡', iso_2: 'MH' },
- { calling_code: '+596', name: 'Martinique', emoji: '🇲🇶', iso_2: 'MQ' },
- { calling_code: '+222', name: 'Mauritania', emoji: '🇲🇷', iso_2: 'MR' },
- { calling_code: '+230', name: 'Mauritius', emoji: '🇲🇺', iso_2: 'MU' },
- { calling_code: '+262', name: 'Mayotte', emoji: '🇾🇹', iso_2: 'YT' },
- { calling_code: '+52', name: 'Mexico', emoji: '🇲🇽', iso_2: 'MX' },
- { calling_code: '+691', name: 'Micronesia', emoji: '🇫🇲', iso_2: 'FM' },
- { calling_code: '+373', name: 'Moldova', emoji: '🇲🇩', iso_2: 'MD' },
- { calling_code: '+377', name: 'Monaco', emoji: '🇲🇨', iso_2: 'MC' },
- { calling_code: '+976', name: 'Mongolia', emoji: '🇲🇳', iso_2: 'MN' },
- { calling_code: '+382', name: 'Montenegro', emoji: '🇲🇪', iso_2: 'ME' },
- { calling_code: '+1-664', name: 'Montserrat', emoji: '🇲🇸', iso_2: 'MS' },
- { calling_code: '+212', name: 'Morocco', emoji: '🇲🇦', iso_2: 'MA' },
- { calling_code: '+258', name: 'Mozambique', emoji: '🇲🇿', iso_2: 'MZ' },
- { calling_code: '+95', name: 'Myanmar', emoji: '🇲🇲', iso_2: 'MM' },
- { calling_code: '+264', name: 'Namibia', emoji: '🇳🇦', iso_2: 'NA' },
- { calling_code: '+674', name: 'Nauru', emoji: '🇳🇷', iso_2: 'NR' },
- { calling_code: '+977', name: 'Nepal', emoji: '🇳🇵', iso_2: 'NP' },
- { calling_code: '+31', name: 'Netherlands', emoji: '🇳🇱', iso_2: 'NL' },
- { calling_code: '+687', name: 'New Caledonia', emoji: '🇳🇨', iso_2: 'NC' },
- { calling_code: '+64', name: 'New Zealand', emoji: '🇳🇿', iso_2: 'NZ' },
- { calling_code: '+505', name: 'Nicaragua', emoji: '🇳🇮', iso_2: 'NI' },
- { calling_code: '+227', name: 'Niger', emoji: '🇳🇪', iso_2: 'NE' },
- { calling_code: '+234', name: 'Nigeria', emoji: '🇳🇬', iso_2: 'NG' },
- { calling_code: '+683', name: 'Niue', emoji: '🇳🇺', iso_2: 'NU' },
- { calling_code: '+672', name: 'Norfolk Island', emoji: '🇳🇫', iso_2: 'NF' },
- { calling_code: '+850', name: 'North Korea', emoji: '🇰🇵', iso_2: 'KP' },
- { calling_code: '+47', name: 'Norway', emoji: '🇳🇴', iso_2: 'NO' },
- { calling_code: '+968', name: 'Oman', emoji: '🇴🇲', iso_2: 'OM' },
- { calling_code: '+92', name: 'Pakistan', emoji: '🇵🇰', iso_2: 'PK' },
- { calling_code: '+680', name: 'Palau', emoji: '🇵🇼', iso_2: 'PW' },
- { calling_code: '+970', name: 'Palestine', emoji: '🇵🇸', iso_2: 'PS' },
- { calling_code: '+507', name: 'Panama', emoji: '🇵🇦', iso_2: 'PA' },
- { calling_code: '+675', name: 'Papua New Guinea', emoji: '🇵🇬', iso_2: 'PG' },
- { calling_code: '+595', name: 'Paraguay', emoji: '🇵🇾', iso_2: 'PY' },
- { calling_code: '+51', name: 'Peru', emoji: '🇵🇪', iso_2: 'PE' },
- { calling_code: '+63', name: 'Philippines', emoji: '🇵ðŸ‡', iso_2: 'PH' },
- { calling_code: '+64', name: 'Pitcairn Islands', emoji: '🇵🇳', iso_2: 'PN' },
- { calling_code: '+48', name: 'Poland', emoji: '🇵🇱', iso_2: 'PL' },
- { calling_code: '+351', name: 'Portugal', emoji: '🇵🇹', iso_2: 'PT' },
- { calling_code: '+1-787', name: 'Puerto Rico', emoji: '🇵🇷', iso_2: 'PR' },
- { calling_code: '+974', name: 'Qatar', emoji: '🇶🇦', iso_2: 'QA' },
- { calling_code: '+40', name: 'Romania', emoji: '🇷🇴', iso_2: 'RO' },
- { calling_code: '+7', name: 'Russia', emoji: '🇷🇺', iso_2: 'RU' },
- { calling_code: '+250', name: 'Rwanda', emoji: '🇷🇼', iso_2: 'RW' },
- { calling_code: '+590', name: 'Saint Barthélemy', emoji: '🇧🇱', iso_2: 'BL' },
- { calling_code: '+290', name: 'Saint Helena, Ascension and Tristan da Cunha', emoji: '🇸ðŸ‡', iso_2: 'SH' },
- { calling_code: '+1-869', name: 'Saint Kitts and Nevis', emoji: '🇰🇳', iso_2: 'KN' },
- { calling_code: '+1-758', name: 'Saint Lucia', emoji: '🇱🇨', iso_2: 'LC' },
- { calling_code: '+590', name: 'Saint Martin', emoji: '🇲🇫', iso_2: 'MF' },
- { calling_code: '+508', name: 'Saint Pierre and Miquelon', emoji: '🇵🇲', iso_2: 'PM' },
- { calling_code: '+1-784', name: 'Saint Vincent and the Grenadines', emoji: '🇻🇨', iso_2: 'VC' },
- { calling_code: '+685', name: 'Samoa', emoji: '🇼🇸', iso_2: 'WS' },
- { calling_code: '+378', name: 'San Marino', emoji: '🇸🇲', iso_2: 'SM' },
- { calling_code: '+239', name: 'Sao Tome and Principe', emoji: '🇸🇹', iso_2: 'ST' },
- { calling_code: '+966', name: 'Saudi Arabia', emoji: '🇸🇦', iso_2: 'SA' },
- { calling_code: '+221', name: 'Senegal', emoji: '🇸🇳', iso_2: 'SN' },
- { calling_code: '+381', name: 'Serbia', emoji: '🇷🇸', iso_2: 'RS' },
- { calling_code: '+248', name: 'Seychelles', emoji: '🇸🇨', iso_2: 'SC' },
- { calling_code: '+232', name: 'Sierra Leone', emoji: '🇸🇱', iso_2: 'SL' },
- { calling_code: '+65', name: 'Singapore', emoji: '🇸🇬', iso_2: 'SG' },
- { calling_code: '+1-721', name: 'Sint Maarten', emoji: '🇸🇽', iso_2: 'SX' },
- { calling_code: '+421', name: 'Slovakia', emoji: '🇸🇰', iso_2: 'SK' },
- { calling_code: '+386', name: 'Slovenia', emoji: '🇸🇮', iso_2: 'SI' },
- { calling_code: '+677', name: 'Solomon Islands', emoji: '🇸🇧', iso_2: 'SB' },
- { calling_code: '+252', name: 'Somalia', emoji: '🇸🇴', iso_2: 'SO' },
- { calling_code: '+27', name: 'South Africa', emoji: '🇿🇦', iso_2: 'ZA' },
- { calling_code: '+82', name: 'South Korea', emoji: '🇰🇷', iso_2: 'KR' },
- { calling_code: '+211', name: 'South Sudan', emoji: '🇸🇸', iso_2: 'SS' },
- { calling_code: '+34', name: 'Spain', emoji: '🇪🇸', iso_2: 'ES' },
- { calling_code: '+94', name: 'Sri Lanka', emoji: '🇱🇰', iso_2: 'LK' },
- { calling_code: '+249', name: 'Sudan', emoji: '🇸🇩', iso_2: 'SD' },
- { calling_code: '+597', name: 'Suriname', emoji: '🇸🇷', iso_2: 'SR' },
- { calling_code: '+47', name: 'Svalbard and Jan Mayen', emoji: '🇸🇯', iso_2: 'SJ' },
- { calling_code: '+46', name: 'Sweden', emoji: '🇸🇪', iso_2: 'SE' },
- { calling_code: '+41', name: 'Switzerland', emoji: '🇨ðŸ‡', iso_2: 'CH' },
- { calling_code: '+963', name: 'Syria', emoji: '🇸🇾', iso_2: 'SY' },
- { calling_code: '+886', name: 'Taiwan', emoji: '🇹🇼', iso_2: 'TW' },
- { calling_code: '+992', name: 'Tajikistan', emoji: '🇹🇯', iso_2: 'TJ' },
- { calling_code: '+255', name: 'Tanzania', emoji: '🇹🇿', iso_2: 'TZ' },
- { calling_code: '+66', name: 'Thailand', emoji: '🇹ðŸ‡', iso_2: 'TH' },
- { calling_code: '+670', name: 'Timor-Leste', emoji: '🇹🇱', iso_2: 'TL' },
- { calling_code: '+228', name: 'Togo', emoji: '🇹🇬', iso_2: 'TG' },
- { calling_code: '+690', name: 'Tokelau', emoji: '🇹🇰', iso_2: 'TK' },
- { calling_code: '+676', name: 'Tonga', emoji: '🇹🇴', iso_2: 'TO' },
- { calling_code: '+1-868', name: 'Trinidad and Tobago', emoji: '🇹🇹', iso_2: 'TT' },
- { calling_code: '+216', name: 'Tunisia', emoji: '🇹🇳', iso_2: 'TN' },
- { calling_code: '+90', name: 'Turkey', emoji: '🇹🇷', iso_2: 'TR' },
- { calling_code: '+993', name: 'Turkmenistan', emoji: '🇹🇲', iso_2: 'TM' },
- { calling_code: '+1-649', name: 'Turks and Caicos Islands', emoji: '🇹🇨', iso_2: 'TC' },
- { calling_code: '+688', name: 'Tuvalu', emoji: '🇹🇻', iso_2: 'TV' },
- { calling_code: '+256', name: 'Uganda', emoji: '🇺🇬', iso_2: 'UG' },
- { calling_code: '+380', name: 'Ukraine', emoji: '🇺🇦', iso_2: 'UA' },
- { calling_code: '+971', name: 'United Arab Emirates', emoji: '🇦🇪', iso_2: 'AE' },
- { calling_code: '+44', name: 'United Kingdom', emoji: '🇬🇧', iso_2: 'GB' },
- { calling_code: '+1', name: 'United States', emoji: '🇺🇸', iso_2: 'US' },
- { calling_code: '+598', name: 'Uruguay', emoji: '🇺🇾', iso_2: 'UY' },
- { calling_code: '+998', name: 'Uzbekistan', emoji: '🇺🇿', iso_2: 'UZ' },
- { calling_code: '+678', name: 'Vanuatu', emoji: '🇻🇺', iso_2: 'VU' },
- { calling_code: '+58', name: 'Venezuela', emoji: '🇻🇪', iso_2: 'VE' },
- { calling_code: '+84', name: 'Vietnam', emoji: '🇻🇳', iso_2: 'VN' },
- { calling_code: '+681', name: 'Wallis and Futuna', emoji: '🇼🇫', iso_2: 'WF' },
- { calling_code: '+212', name: 'Western Sahara', emoji: '🇪ðŸ‡', iso_2: 'EH' },
- { calling_code: '+967', name: 'Yemen', emoji: '🇾🇪', iso_2: 'YE' },
- { calling_code: '+260', name: 'Zambia', emoji: '🇿🇲', iso_2: 'ZM' },
- { calling_code: '+263', name: 'Zimbabwe', emoji: '🇿🇼', iso_2: 'ZW' }
-]
+import countries from '@countries'
export const countryCallingOptions = countries.map((country) => ({
label: country.name,
@@ -252,4 +13,4 @@ export const countryOptions = countries.map((country) => ({
emoji: country.emoji
}))
-export default countries;
\ No newline at end of file
+export default countries;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 8282feb25..92a754fc7 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -71,6 +71,10 @@ export default defineConfig(({ mode, command }) => {
target: apiTarget,
changeOrigin: true,
},
+ '/webhooks': {
+ target: apiTarget,
+ changeOrigin: true,
+ },
'/ws': {
target: wsTarget,
ws: true,
@@ -130,6 +134,7 @@ export default defineConfig(({ mode, command }) => {
'@main': path.resolve(__dirname, 'apps/main/src'),
'@widget': path.resolve(__dirname, 'apps/widget/src'),
'@shared-ui': path.resolve(__dirname, 'shared-ui'),
+ '@countries': path.resolve(__dirname, '../internal/countries/countries.json'),
'@public-static': path.resolve(__dirname, '../static/public/static'),
},
},
diff --git a/go.mod b/go.mod
index 1855f052e..94a187221 100644
--- a/go.mod
+++ b/go.mod
@@ -49,6 +49,7 @@ require (
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.52.0
+ golang.org/x/image v0.41.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.27.0
@@ -95,7 +96,6 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
- golang.org/x/image v0.41.0 // indirect
golang.org/x/sys v0.45.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/i18n/en-US.json b/i18n/en-US.json
index 74923f4fb..b6915a03a 100644
--- a/i18n/en-US.json
+++ b/i18n/en-US.json
@@ -99,7 +99,6 @@
"admin.businessHours.setBusinessHours": "Set business hours",
"admin.contextLink.help.description": "Context links appear in the conversation sidebar, letting agents open external tools like CRMs, billing systems, or internal dashboards with the contact's details passed automatically.",
"admin.contextLink.help.detail": "Use {'{{token}}'} for a fully encrypted payload that the external app decrypts with a shared secret. Or use individual variables like {'{{email}}'}, {'{{phone}}'}, {'{{external_user_id}}'} for plain URL links to trusted internal tools.",
- "admin.conversationStatus.category.placeholder": "Select category",
"admin.conversationStatus.name.description": "Set status name. Click save when you're done.",
"admin.conversationTags.edit.description": "Change the tag name. Click save when you're done.",
"admin.conversationTags.name.valid": "Tag name should be at least 3 characters",
@@ -158,7 +157,6 @@
"admin.ai.testSuccess": "Connection successful. The provider accepted the request.",
"admin.ai.tool.nameHint": "Letters, numbers, hyphen and underscore only, up to 64 characters.",
"admin.ai.tool.method": "Method",
- "admin.ai.tool.headers": "Headers",
"admin.ai.tool.headersHint": "Sent with every request to this tool's URL. Values are encrypted at rest.",
"admin.ai.tool.headersInvalid": "Enter both a name and a value for every header, or remove the incomplete one.",
"admin.ai.tool.headerSecretMissing": "This header has no saved value. Renaming a header does not carry over its stored secret. Enter the value again.",
@@ -274,6 +272,8 @@
"admin.general.showConversationSubject": "Show subject in conversation list",
"admin.general.showConversationSubject.description": "Display the conversation subject line in the conversation list when available.",
"admin.general.timezone.placeholder": "Select timezone",
+ "admin.inbox.authenticationFailed": "Authentication failed. This inbox can't send or receive messages until you update its credentials.",
+ "admin.inbox.email.authenticationFailed": "Authentication failed. New emails won't be fetched and replies won't be sent until you update this inbox's credentials.",
"admin.inbox.authProtocol": "Authentication Protocol",
"admin.inbox.authProtocol.description": "Authentication protocol to use.",
"admin.inbox.authProtocol.login": "Login",
@@ -281,8 +281,9 @@
"admin.inbox.chooseChannel": "Choose channel",
"admin.inbox.createEmailInbox": "Create an email inbox for email-based customer support",
"admin.inbox.createLiveChatInbox": "Create a live chat inbox for real-time customer support",
+ "admin.inbox.createWhatsAppInbox": "Create a WhatsApp inbox to receive and reply to contact messages on WhatsApp",
"admin.inbox.csatSurveys": "CSAT Surveys",
- "admin.inbox.csatSurveys.description_1": "Send customer satisfaction surveys when conversation is marked as resolved.",
+ "admin.inbox.csatSurveys.description_1": "Send customer satisfaction surveys when a conversation is marked as resolved.",
"admin.inbox.csatSurveys.description_2": "For better control on when to send surveys, disable this option and create an automation rule to send surveys.",
"admin.inbox.csatSurveys.description_3": "CSAT surveys are only sent once per conversation.",
"admin.inbox.enablePlusAddressing": "Enable plus addressing",
@@ -295,6 +296,7 @@
"admin.inbox.heloHostname.description": "The hostname to use in the HELO/EHLO command. If not set, defaults to localhost.",
"admin.inbox.help.email": "Connect your Google or Microsoft email account, or configure IMAP and SMTP settings manually. Each added inbox creates a new email channel for receiving customer emails.",
"admin.inbox.help.livechat": "Create live chat widgets that can be embedded on your website for real-time customer support.",
+ "admin.inbox.help.whatsapp": "Connect a WhatsApp number through the Meta Cloud API to receive and reply to contact messages on WhatsApp.",
"admin.inbox.idleTimeout": "Idle Timeout",
"admin.inbox.idleTimeout.description": "Maximum time an inactive connection must be kept alive before closing it and removing it from the pool.",
"admin.inbox.imap.tls.description": "Choose the encryption method for IMAP.",
@@ -444,6 +446,49 @@
"admin.inbox.tls.description": "TLS/SSL encryption, STARTTLS is commonly used.",
"admin.inbox.waitTimeout": "Wait Timeout",
"admin.inbox.waitTimeout.description": "Maximum time to wait to obtain a connection before timing out. Timeouts may occur when all open connections are busy sending e-mails and they're not returning to the pool fast enough. This is also the timeout used when creating new SMTP connections.",
+ "admin.inbox.whatsapp.accessToken.description": "Use a permanent System User token. Temporary tokens expire in 24 hours.",
+ "admin.inbox.whatsapp.apiVersion.description": "Leave this as it is unless Meta asks you to change it.",
+ "admin.inbox.whatsapp.csatTemplate.description": "This message is registered with Meta as a template and sent when a WhatsApp conversation is resolved.",
+ "admin.inbox.whatsapp.csatTemplateBody.description": "Shown above the rating button. Meta reviews template content before approving it.",
+ "admin.inbox.whatsapp.reopenWindow": "Reopen window (hours)",
+ "admin.inbox.whatsapp.reopenWindow.description": "If a contact replies within this many hours of a conversation being resolved, it reopens instead of starting a new one. Set to 0 to always start a new conversation.",
+ "admin.inbox.whatsapp.enabled.description": "Receive and send messages on this WhatsApp number.",
+ "admin.inbox.whatsapp.appSecret.description": "Found under App settings > Basic in your Meta app.",
+ "admin.inbox.whatsapp.error.invalidConfig": "This inbox's WhatsApp settings could not be read. Re-enter the credentials and save again.",
+ "admin.inbox.whatsapp.error.credentialCheckFailed": "Meta rejected these credentials: {error}",
+ "admin.inbox.whatsapp.metaCredentials": "Meta Cloud API credentials",
+ "admin.inbox.whatsapp.phoneNumberID": "Phone number ID",
+ "admin.inbox.whatsapp.phoneNumberID.description": "Found on the WhatsApp API Setup page in your Meta app.",
+ "admin.inbox.whatsapp.phoneNumberID.placeholder": "1234567890",
+ "admin.inbox.whatsapp.tokenInvalid": "Meta rejected this inbox's access token. Messages and media will fail to send or download until you paste a new token below and save.",
+ "admin.inbox.whatsapp.verifyToken": "Webhook verify token",
+ "admin.inbox.whatsapp.verifyToken.description": "Any random string. Paste the same value into Meta's webhook configuration.",
+ "admin.inbox.whatsapp.verifyToken.placeholder": "lib_wh_a8s7d6f5",
+ "admin.inbox.whatsapp.wabaID": "WhatsApp Business Account ID",
+ "admin.inbox.whatsapp.wabaID.placeholder": "9876543210",
+ "admin.inbox.whatsapp.webhook.description": "Paste this URL and the verify token into Meta > Configuration > Webhooks, then subscribe to the messages and message_template_status_update fields.",
+ "admin.inbox.whatsapp.webhookURL.afterSave": "Save the inbox to generate the webhook URL.",
+ "admin.whatsappTemplates.bodyText.description": "Add placeholders for values you fill in when sending - either numbered ({'{{1}}'}, {'{{2}}'}) or named ({'{{order_id}}'}), but don't mix the two styles in one template. Example: Hi {'{{1}}'}, your order {'{{2}}'} is now {'{{3}}'}.",
+ "admin.whatsappTemplates.bodyText.placeholder": "Hi {'{{1}}'}, your order {'{{2}}'} is on its way.",
+ "admin.whatsappTemplates.category.description": "Category affects approval time and pricing.",
+ "admin.whatsappTemplates.confirmDelete": "Delete this template on libredesk and Meta?",
+ "admin.whatsappTemplates.csatReserved": "Auto-created for the customer satisfaction (CSAT) survey sent when a conversation is resolved. Edit its message, button, and language in the inbox's CSAT settings. It can't be deleted here.",
+ "admin.whatsappTemplates.error.reserved": "This template is reserved for CSAT surveys and can't be deleted. Edit it in the inbox's CSAT settings.",
+ "admin.whatsappTemplates.footer": "Footer (optional)",
+ "admin.whatsappTemplates.footer.description": "Maximum 60 characters.",
+ "admin.whatsappTemplates.headerText.description": "Optional. Add one placeholder - {'{{1}}'} or a named one like {'{{order_id}}'} - matching the style used in the body, if the header text changes per message.",
+ "admin.whatsappTemplates.help.create": "Build the template's header, body, buttons, and sample values, then submit it to Meta. Approval can take a while; the status updates here automatically once Meta responds.",
+ "admin.whatsappTemplates.help.overview": "Message templates are pre-approved formats required to start a WhatsApp conversation or reply outside the 24-hour window. Create one here, or sync templates you already approved in Meta.",
+ "admin.whatsappTemplates.language.description": "Must match the language of the text, or Meta rejects the template.",
+ "admin.whatsappTemplates.name.description": "Lowercase letters, numbers, and underscores only.",
+ "admin.whatsappTemplates.nameInvalid": "Use lowercase letters, numbers, and underscores only.",
+ "admin.whatsappTemplates.noInboxes": "Create a WhatsApp inbox first to author templates.",
+ "admin.whatsappTemplates.sampleValues.description": "Meta requires example values for each placeholder. These are only used during approval and never sent to contacts.",
+ "admin.whatsappTemplates.submit": "Submit to Meta",
+ "admin.whatsappTemplates.submitted": "Template submitted to Meta. Approval usually takes a few minutes.",
+ "admin.whatsappTemplates.syncFromMeta": "Sync from Meta",
+ "admin.whatsappTemplates.synced": "Synced {count} template(s) from Meta.",
+ "admin.whatsappTemplates.title": "WhatsApp templates",
"admin.macro.actionInvalid": "Each action must have a type and a value",
"admin.macro.help": "Combine multiple conversation actions into single-click macros.",
"admin.macro.messageContent": "Response to be sent when macro is used (optional)",
@@ -724,6 +769,34 @@
"conversation.teamAssigned": "Team assigned",
"conversation.tryAdjustingFilters": "Try adjusting filters",
"conversation.viewPermissionDenied": "You do not have access to this view",
+ "conversation.whatsapp.csatMessage": "Your conversation has been resolved. How did we do? Rate your experience: {link}",
+ "conversation.whatsapp.csatNotSent": "CSAT survey was not sent. The WhatsApp reply window is closed.",
+ "conversation.whatsapp.error.contactNoPhone": "This contact has no phone number. Add one to message them on WhatsApp.",
+ "conversation.whatsapp.error.contactCountryCodeInvalid": "This contact's phone number has no valid country code. Fix it on the contact to message them on WhatsApp.",
+ "conversation.whatsapp.error.numberLinkedToAnotherContact": "This phone number already belongs to another contact on WhatsApp. Merge or correct the contacts first.",
+ "conversation.whatsapp.error.templateStoreUnavailable": "WhatsApp templates are unavailable right now. Try again in a moment.",
+ "conversation.whatsapp.error.templateWrongInbox": "That template belongs to another inbox. Pick one from this inbox's templates.",
+ "conversation.whatsapp.error.templateNotApproved": "Meta has not approved this template yet (status: {status}). Wait for approval or pick another template.",
+ "conversation.whatsapp.error.templateHeaderUnsupported": "Templates with a {type} header can't be sent from libredesk yet. Pick a template with a text header or none.",
+ "conversation.whatsapp.error.windowClosed": "The 24-hour reply window has closed. Send an approved template instead.",
+ "conversation.whatsapp.error.contentRequired": "Type a message or attach a file before sending.",
+ "conversation.whatsapp.error.tooLong": "This message is longer than WhatsApp's limit of {limit} characters. Shorten it and send again.",
+ "conversation.whatsapp.error.missingBodyParam": "Fill in the {placeholder} value before sending this template.",
+ "conversation.whatsapp.error.missingHeaderParam": "Fill in the header's {placeholder} value before sending this template.",
+ "conversation.whatsapp.error.missingButtonParam": "Fill in the link value for the {button} button before sending this template.",
+ "conversation.whatsapp.error.oneAttachment": "WhatsApp takes one file per message. Send the files one at a time.",
+ "conversation.whatsapp.error.phoneCountryCodeInvalid": "Pick a valid country for the phone number.",
+ "conversation.whatsapp.error.phoneCountryMismatch": "This number does not match the country you picked. Fix one of them.",
+ "conversation.whatsapp.error.phoneInvalid": "Enter a valid phone number.",
+ "conversation.whatsapp.noApprovedTemplates": "No approved templates yet. Create and submit one from Admin then WhatsApp templates.",
+ "conversation.whatsapp.numberPlaceholder": "Search or enter a number",
+ "conversation.whatsapp.selectInboxFirst": "Select an inbox to load its templates.",
+ "conversation.whatsapp.templateSent": "Template queued for delivery.",
+ "conversation.whatsapp.unsupportedMessage": "The contact sent something WhatsApp does not deliver to business tools, like a poll or an animated sticker. It is only visible on their phone.",
+ "conversation.whatsapp.windowClosed.description": "Meta only allows template messages once a contact hasn't messaged for over 24 hours.",
+ "conversation.whatsapp.windowClosed.title": "24-hour reply window closed",
+ "conversation.whatsapp.windowClosing": "Reply window closes in {time}. After that, only template messages can be sent.",
+ "conversation.whatsapp.fileSizeExceeded": "{name} exceeds the {size} MB limit for this file type on WhatsApp.",
"conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
"conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
"csat.alreadySubmitted": "CSAT already submitted",
@@ -783,6 +856,7 @@
"filter.toggleConnector": "Click to switch between and / or",
"globals.messages.add": "Add",
"globals.messages.addAnnouncement": "Add announcement",
+ "globals.messages.addButton": "Add button",
"globals.messages.addEmoji": "Add emoji",
"globals.messages.addExternalLink": "Add external link",
"globals.messages.additionalFeedback": "Additional feedback (optional)",
@@ -852,6 +926,7 @@
"globals.messages.mustBeNumber": "Must be a number",
"globals.messages.nDays": "{days} days",
"globals.messages.new": "New",
+ "globals.messages.newTemplate": "New template",
"globals.messages.moveUp": "Move up",
"globals.messages.moveDown": "Move down",
"globals.messages.no": "No",
@@ -880,8 +955,10 @@
"globals.messages.savedSuccessfully": "Changes saved",
"globals.messages.saving": "Saving...",
"globals.messages.selectAFutureTime": "Select a future time",
+ "globals.messages.selectCategory": "Select category",
"globals.messages.selectTLS": "Select TLS type",
"globals.messages.send": "Send",
+ "globals.messages.sendTemplate": "Send template",
"globals.messages.sendUsMessage": "Send us a message",
"globals.messages.sending": "Sending...",
"globals.messages.setUp": "Set up",
@@ -907,6 +984,7 @@
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
"globals.messages.wellBeBack": "We'll be back {when}",
"globals.messages.yes": "Yes",
+ "globals.terms.accessToken": "Access token",
"globals.terms.account": "Account | Accounts",
"globals.terms.action": "Action | Actions",
"globals.terms.active": "Active",
@@ -917,7 +995,9 @@
"globals.terms.alert": "Alert | Alerts",
"globals.terms.announcement": "Announcement | Announcements",
"globals.terms.apiKey": "API key | API keys",
+ "globals.terms.apiVersion": "API version",
"globals.terms.appliesTo": "Applies to",
+ "globals.terms.appSecret": "App secret",
"globals.terms.article": "Article | Articles",
"globals.terms.ascending": "Ascending",
"globals.terms.assignedTeam": "Assigned team",
@@ -935,9 +1015,12 @@
"globals.terms.blocked": "Blocked",
"globals.terms.bold": "Bold",
"globals.terms.body": "Body",
+ "globals.terms.bodyText": "Body text",
"globals.terms.brandName": "Brand name",
"globals.terms.breadcrumb": "Breadcrumb",
"globals.terms.businessHour": "Business hour | Business hours",
+ "globals.terms.button": "Button | Buttons",
+ "globals.terms.buttonText": "Button text",
"globals.terms.callbackURL": "Callback URL",
"globals.terms.category": "Category",
"globals.terms.channel": "Channel",
@@ -965,6 +1048,7 @@
"globals.terms.createdOn": "Created on",
"globals.terms.csatFeedback": "CSAT feedback",
"globals.terms.csatRating": "CSAT rating",
+ "globals.terms.csatSurveyTemplate": "CSAT survey template",
"globals.terms.custom": "Custom",
"globals.terms.customAttribute": "Custom attribute | Custom attributes",
"globals.terms.date": "Date",
@@ -1000,6 +1084,9 @@
"globals.terms.google": "Google",
"globals.terms.gradient": "Gradient",
"globals.terms.great": "Great",
+ "globals.terms.header": "Header | Headers",
+ "globals.terms.headerText": "Header text",
+ "globals.terms.headerType": "Header type",
"globals.terms.helpCenter": "Help center | Help centers",
"globals.terms.home": "Home",
"globals.terms.homeScreen": "Home screen",
@@ -1032,7 +1119,7 @@
"globals.terms.listValues": "List values",
"globals.terms.liveChat": "Live Chat",
"globals.terms.loadMore": "Load more",
- "globals.terms.loading": "Loading...",
+ "globals.terms.loading": "Loading",
"globals.terms.log": "Log | Logs",
"globals.terms.logoUrl": "Logo URL",
"globals.terms.macro": "Macro | Macros",
@@ -1089,6 +1176,7 @@
"globals.terms.resolvedAt": "Resolved at",
"globals.terms.role": "Role | Roles",
"globals.terms.rootURL": "Root URL",
+ "globals.terms.sampleValue": "Sample value | Sample values",
"globals.terms.search": "Search",
"globals.terms.secondaryColor": "Secondary color | Secondary colors",
"globals.terms.secret": "Secret | Secrets",
@@ -1151,6 +1239,7 @@
"globals.terms.summary": "Summary",
"globals.terms.webhook": "Webhook | Webhooks",
"globals.terms.week": "Week | Weeks",
+ "globals.terms.whatsapp": "WhatsApp",
"globals.terms.white": "White",
"globals.terms.workspace": "Workspace",
"globals.terms.yesterday": "Yesterday",
@@ -1177,7 +1266,6 @@
"helpCenter.deleteConfirmation": "This will permanently delete the help center, including all its collections and articles.",
"helpCenter.editArticle": "Edit article",
"helpCenter.editCollection": "Edit collection",
- "helpCenter.headerText": "Header text",
"helpCenter.homeMetaDescriptionHint": "Shown in search-engine results and social previews for the help center home page. Leave blank to use the header text.",
"helpCenter.invalidColor": "Accent color must be a hex color code like #1f93ff.",
"helpCenter.invalidParent": "A collection cannot be its own parent.",
@@ -1456,7 +1544,6 @@
"template.defaultTemplateAlreadyExists": "Default template already exists",
"template.deletionConfirmation": "This action cannot be undone. This will permanently delete this template.",
"template.edit": "Edit template",
- "template.new": "New template",
"toast.apiKeyGenerated": "API key generated",
"toast.authorizationDenied": "Authorization denied",
"toast.avatarUpdated": "Avatar updated successfully",
diff --git a/internal/automation/models/models.go b/internal/automation/models/models.go
index 30102a082..203d479a5 100644
--- a/internal/automation/models/models.go
+++ b/internal/automation/models/models.go
@@ -43,18 +43,18 @@ const (
RuleTypeConversationUpdate = "conversation_update"
RuleTypeTimeTrigger = "time_trigger"
- ConversationSubject = "subject"
- ConversationContent = "content"
- ConversationStatus = "status"
- ConversationPriority = "priority"
- ConversationAssignedUser = "assigned_user"
- ConversationAssignedTeam = "assigned_team"
- ConversationHoursSinceCreated = "hours_since_created"
- ConversationHoursSinceFirstReply = "hours_since_first_reply"
- ConversationHoursSinceLastReply = "hours_since_last_reply"
- ConversationHoursSinceResolved = "hours_since_resolved"
- ConversationInbox = "inbox"
- ContactEmail = "contact_email"
+ ConversationSubject = "subject"
+ ConversationContent = "content"
+ ConversationStatus = "status"
+ ConversationPriority = "priority"
+ ConversationAssignedUser = "assigned_user"
+ ConversationAssignedTeam = "assigned_team"
+ ConversationHoursSinceCreated = "hours_since_created"
+ ConversationHoursSinceFirstReply = "hours_since_first_reply"
+ ConversationHoursSinceLastReply = "hours_since_last_reply"
+ ConversationHoursSinceResolved = "hours_since_resolved"
+ ConversationInbox = "inbox"
+ ContactEmail = "contact_email"
ConversationPreviousStatus = "previous_status"
ConversationPreviousPriority = "previous_priority"
diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go
index c70e04881..f9a913f88 100644
--- a/internal/conversation/conversation.go
+++ b/internal/conversation/conversation.go
@@ -37,6 +37,7 @@ import (
"github.com/abhinavxd/libredesk/internal/template"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
+ wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
@@ -51,6 +52,7 @@ var (
efs embed.FS
errConversationNotFound = errors.New("conversation not found")
ErrConversationAlreadyAssigned = errors.New("conversation already assigned")
+ ErrMessageNotFound = errors.New("message not found")
conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "last_interaction_sender", "created_at", "waiting_since", "next_sla_deadline_at", "snoozed_until", "sla_policy_id"}
conversationStatusAllowedFields = []string{"id", "name"}
usersAllowedFields = []string{"email", "external_user_id"}
@@ -100,6 +102,7 @@ type Manager struct {
automation *automation.Engine
wsHub *ws.Hub
template *template.Manager
+ whatsappTemplate WhatsAppTemplateStore
incomingMessageQueue chan models.IncomingMessage
outgoingMessageQueue chan models.Message
outgoingProcessingMessages sync.Map
@@ -168,6 +171,8 @@ type userStore interface {
GetSystemUser() (umodels.User, error)
ResolveContact(user *umodels.User, policy umodels.ContactPolicy) error
UpgradeVisitorToContact(visitorID int) error
+ GetChannelIdentity(contactID int, channel string) (string, error)
+ LinkChannelIdentity(contactID int, channel, identifier string) (int, error)
}
type mediaStore interface {
@@ -208,6 +213,13 @@ type webhookStore interface {
TriggerWebhook(webhookID int, event wmodels.WebhookEvent, data any)
}
+// WhatsAppTemplateStore is an interface over internal/whatsapp_template to avoid a circular import.
+type WhatsAppTemplateStore interface {
+ GetByID(id int) (wtmodels.Template, error)
+ GetByName(inboxID int, name string) (wtmodels.Template, error)
+ GetApproved(inboxID int, name, language string) (wtmodels.Template, error)
+}
+
// ContinuityConfig holds configuration for conversation continuity emails
type ContinuityConfig struct {
BatchCheckInterval time.Duration
@@ -295,9 +307,15 @@ func New(
return c, nil
}
+// SetWhatsAppTemplateStore wires the WhatsApp template store after construction; nil disables template sends.
+func (m *Manager) SetWhatsAppTemplateStore(s WhatsAppTemplateStore) {
+ m.whatsappTemplate = s
+}
+
type queries struct {
// Conversation queries.
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
+ GetConversationInboxContact *sqlx.Stmt `query:"get-conversation-inbox-contact"`
GetConversation *sqlx.Stmt `query:"get-conversation"`
GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"`
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
@@ -350,7 +368,16 @@ type queries struct {
GetConversationByMessageID *sqlx.Stmt `query:"get-conversation-by-message-id"`
InsertMessage *sqlx.Stmt `query:"insert-message"`
UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"`
+ MarkMessagePendingForRetry *sqlx.Stmt `query:"mark-message-pending-for-retry"`
UpdateMessageSourceID *sqlx.Stmt `query:"update-message-source-id"`
+ UpdateMessageSourceIDByUUID *sqlx.Stmt `query:"update-message-source-id-by-uuid"`
+ ApplyWhatsAppMessageStatus *sqlx.Stmt `query:"apply-whatsapp-message-status"`
+ MergeMessageMetaByUUID *sqlx.Stmt `query:"merge-message-meta-by-uuid"`
+ GetWhatsAppReadReceiptTarget *sqlx.Stmt `query:"get-whatsapp-read-receipt-target"`
+ UpdateConversationLastInboundAt *sqlx.Stmt `query:"update-conversation-last-inbound-at"`
+ GetContactWindowInboundAt *sqlx.Stmt `query:"get-contact-window-inbound-at"`
+ GetLatestOpenConversationByContact *sqlx.Stmt `query:"get-latest-open-conversation-by-contact-inbox"`
+ GetReopenableConversationByContact *sqlx.Stmt `query:"get-latest-reopenable-conversation-by-contact-inbox"`
DeleteMessage *sqlx.Stmt `query:"delete-message"`
DeletePrivateMessage *sqlx.Stmt `query:"delete-private-message"`
@@ -367,8 +394,9 @@ type queries struct {
GetActiveLivechatConversationsByAgent *sqlx.Stmt `query:"get-active-livechat-conversations-by-agent"`
// WS list-subscribe authz.
- FilterAuthorizedListUUIDs *sqlx.Stmt `query:"filter-authorized-list-uuids"`
- GetConversationUUIDsByContact *sqlx.Stmt `query:"get-conversation-uuids-by-contact"`
+ FilterAuthorizedListUUIDs *sqlx.Stmt `query:"filter-authorized-list-uuids"`
+ GetConversationUUIDsByContact *sqlx.Stmt `query:"get-conversation-uuids-by-contact"`
+ GetConversationUUIDsByContactInbox *sqlx.Stmt `query:"get-conversation-uuids-by-contact-inbox"`
}
// CreateConversation creates a new conversation. If maxConversations > 0, the insert is
@@ -435,8 +463,8 @@ func (c *Manager) GetConversation(id int, uuid, refNum string) (models.Conversat
return conversation, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- // Strip name and extract plain email from "Name
"
- if conversation.InboxMail != "" {
+ // Only email inboxes carry an address here; other channels store a display name in inbox_mail.
+ if conversation.InboxChannel == inbox.ChannelEmail && conversation.InboxMail != "" {
var err error
conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail)
if err != nil {
@@ -1458,9 +1486,13 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
case amodels.ActionReply:
// Automated replies always go to the contact only. CCs from the
// conversation history are deliberately not carried forward.
- if conv.Contact.Email.String == "" {
+ if conv.InboxChannel == inbox.ChannelEmail && conv.Contact.Email.String == "" {
return fmt.Errorf("auto-reply skipped: contact has no email for conversation: %s", conv.UUID)
}
+ var to []string
+ if conv.Contact.Email.String != "" {
+ to = []string{conv.Contact.Email.String}
+ }
_, err := m.QueueReply(
[]mmodels.Media{},
conv.InboxID,
@@ -1468,7 +1500,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
conv.ContactID,
conv.UUID,
action.Value[0],
- []string{conv.Contact.Email.String},
+ to,
nil,
nil,
map[string]any{"is_automated": true},
@@ -1713,10 +1745,10 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use
return nil
}
-// SendCSATReply sends a CSAT reply message to a conversation. No-op if one was already sent or contact has no email.
+// SendCSATReply is a no-op if one was already sent or an email contact has no email.
func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversation) error {
- if conversation.Contact.Email.String == "" {
- m.lo.Info("CSAT reply skipped: contact has no email for conversation: %s", "conversation_uuid", conversation.UUID)
+ if conversation.InboxChannel == inbox.ChannelEmail && conversation.Contact.Email.String == "" {
+ m.lo.Info("CSAT reply skipped: contact has no email", "conversation_uuid", conversation.UUID)
return nil
}
csatResp, err := m.csatStore.Create(conversation.ID)
@@ -1732,6 +1764,10 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
}
csatPublicURL := m.csatStore.MakePublicURL(appRootURL, csatResp.UUID)
+ if conversation.InboxChannel == inbox.ChannelWhatsApp {
+ return m.sendWhatsAppCSAT(actorUserID, conversation, csatResp.UUID, csatPublicURL)
+ }
+
// Render CSAT email template.
data, err := m.BuildTemplateData(conversation.UUID, actorUserID)
if err != nil {
@@ -1753,7 +1789,11 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
}
// Only send CSAT to contact.
- _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, []string{conversation.Contact.Email.String}, nil, nil, meta)
+ var to []string
+ if conversation.Contact.Email.String != "" {
+ to = []string{conversation.Contact.Email.String}
+ }
+ _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, nil, nil, meta)
if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 3cb94652b..157e1b485 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -22,6 +22,7 @@ import (
"github.com/abhinavxd/libredesk/internal/image"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
+ whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/stringutil"
@@ -188,6 +189,9 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
// Send message
err = inb.Send(outbound)
if err != nil && err != livechat.ErrClientNotConnected {
+ if inb.Channel() == inbox.ChannelWhatsApp {
+ m.RecordWhatsAppSendFailure(message.UUID, err.Error())
+ }
handleError(err, "error sending message")
return
}
@@ -317,8 +321,7 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag
m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
return fmt.Errorf("could not render email content using template: %w", err)
}
- case inbox.ChannelLiveChat:
- // Live chat doesn't use templates for rendering messages.
+ case inbox.ChannelLiveChat, inbox.ChannelWhatsApp:
return nil
default:
m.lo.Warn("unknown message channel", "channel", channel)
@@ -414,10 +417,15 @@ func (m *Manager) SignAttachmentURLs(attachments attachment.Attachments) {
// UpdateMessageStatus updates the status of a message.
func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
- if _, err := m.q.UpdateMessageStatus.Exec(status, messageUUID); err != nil {
+ res, err := m.q.UpdateMessageStatus.Exec(status, messageUUID)
+ if err != nil {
m.lo.Error("error updating message status", "message_uuid", messageUUID, "error", err)
return err
}
+ // The sent-onto-failed guard can make this a no-op; a status that wasn't applied must not be broadcast.
+ if n, _ := res.RowsAffected(); n == 0 {
+ return nil
+ }
// Broadcast message status update to all conversation subscribers.
conversationUUID, _ := m.getConversationUUIDFromMessageUUID(messageUUID)
@@ -433,12 +441,80 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
return nil
}
-// MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
+func (m *Manager) UpdateMessageSourceID(messageUUID, sourceID string) error {
+ if messageUUID == "" || sourceID == "" {
+ return nil
+ }
+ if _, err := m.q.UpdateMessageSourceIDByUUID.Exec(messageUUID, sourceID); err != nil {
+ m.lo.Error("error updating message source id", "message_uuid", messageUUID, "error", err)
+ return err
+ }
+ return nil
+}
+
+// UpdateConversationLastInboundAt advances the clock gating business-initiated messages (WhatsApp 24h window).
+func (m *Manager) UpdateConversationLastInboundAt(conversationID int, at time.Time) error {
+ if at.IsZero() {
+ at = time.Now()
+ }
+ var row struct {
+ ContactID int `db:"contact_id"`
+ InboxID int `db:"inbox_id"`
+ }
+ if err := m.q.UpdateConversationLastInboundAt.QueryRow(conversationID, at).Scan(&row.ContactID, &row.InboxID); err != nil {
+ m.lo.Error("error updating conversation last_inbound_at", "conversation_id", conversationID, "error", err)
+ return err
+ }
+ var windowAt sql.NullTime
+ if err := m.q.GetContactWindowInboundAt.Get(&windowAt, row.ContactID, row.InboxID); err != nil {
+ m.lo.Error("error fetching contact window for broadcast", "contact_id", row.ContactID, "inbox_id", row.InboxID, "error", err)
+ return nil
+ }
+ if !windowAt.Valid {
+ return nil
+ }
+ var uuids []string
+ if err := m.q.GetConversationUUIDsByContactInbox.Select(&uuids, row.ContactID, row.InboxID); err != nil {
+ m.lo.Error("error fetching contact's conversations for broadcast", "contact_id", row.ContactID, "inbox_id", row.InboxID, "error", err)
+ return nil
+ }
+ for _, uuid := range uuids {
+ m.BroadcastConversationUpdate(uuid, map[string]any{"contact_last_inbound_at": windowAt.Time.Format(time.RFC3339)})
+ }
+ return nil
+}
+
+// GetLatestOpenConversationForContact returns the most recent non-resolved conversation for a (contact, inbox) pair, or sql.ErrNoRows.
+func (m *Manager) GetLatestOpenConversationForContact(contactID, inboxID int) (int, string, error) {
+ var row struct {
+ ID int `db:"id"`
+ UUID string `db:"uuid"`
+ }
+ if err := m.q.GetLatestOpenConversationByContact.Get(&row, contactID, inboxID); err != nil {
+ return 0, "", err
+ }
+ return row.ID, row.UUID, nil
+}
+
+// GetReopenableConversationForContact returns the most recent resolved conversation for a (contact, inbox) pair last resolved within windowHours, or sql.ErrNoRows.
+func (m *Manager) GetReopenableConversationForContact(contactID, inboxID, windowHours int) (int, string, error) {
+ var row struct {
+ ID int `db:"id"`
+ UUID string `db:"uuid"`
+ }
+ if err := m.q.GetReopenableConversationByContact.Get(&row, contactID, inboxID, windowHours); err != nil {
+ return 0, "", err
+ }
+ return row.ID, row.UUID, nil
+}
+
func (m *Manager) MarkMessageAsPending(uuid string) error {
- if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
+ if _, err := m.q.MarkMessagePendingForRetry.Exec(uuid); err != nil {
m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorSendingMessage"), nil)
}
+ conversationUUID, _ := m.getConversationUUIDFromMessageUUID(uuid)
+ m.BroadcastMessageUpdate(conversationUUID, uuid, map[string]any{"status": models.MessageStatusPending})
return nil
}
@@ -540,6 +616,22 @@ func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID
m.lo.Error("error generating source message id", "error", err)
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+ case inbox.ChannelWhatsApp:
+ // Meta accepts one media per message, so a multi-attachment reply must be sent as separate messages.
+ if len(media) > 1 {
+ return models.Message{}, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.oneAttachment"), nil)
+ }
+ // Reject unsendable media here; Meta's upload endpoint enforces the same caps and would only fail after the message is queued.
+ for _, md := range media {
+ if reason := whatsappChannel.RejectMediaReason(md.Filename, md.ContentType, md.Size); reason != "" {
+ return models.Message{}, envelope.NewError(envelope.InputError, reason, nil)
+ }
+ }
+ rendered, err := m.prepareWhatsAppOutbound(inboxRecord, conversationUUID, content, len(media) > 0, metaMap)
+ if err != nil {
+ return models.Message{}, err
+ }
+ content = rendered
}
// Marshal meta.
@@ -550,8 +642,10 @@ func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID
}
// Best-effort render template variables before saving so agents see rendered content immediately.
- if data, err := m.BuildTemplateData(conversationUUID, senderID); err == nil {
- content = m.template.RenderString(data, content)
+ if inboxRecord.Channel != inbox.ChannelWhatsApp {
+ if data, err := m.BuildTemplateData(conversationUUID, senderID); err == nil {
+ content = m.template.RenderString(data, content)
+ }
}
// Insert the message into the database
@@ -565,7 +659,7 @@ func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID
ContentType: models.ContentTypeHTML,
Private: false,
Media: media,
- SourceID: null.StringFrom(sourceID),
+ SourceID: null.NewString(sourceID, sourceID != ""),
MessageReceiverID: contactID,
Meta: metaJSON,
}
@@ -781,6 +875,8 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
content = fmt.Sprintf("%s set %s SLA policy", actorName, newValue)
case models.ActivityParticipantAdded:
content = fmt.Sprintf("%s joined the conversation", newValue)
+ case models.ActivityCSATNotSent:
+ content = m.i18n.T("conversation.whatsapp.csatNotSent")
default:
return "", fmt.Errorf("invalid activity type %s", activityType)
}
@@ -1002,6 +1098,27 @@ func (m *Manager) ProcessIncomingLiveChatMessage(msg models.Message) (models.Mes
return msg, nil
}
+// ProcessIncomingWhatsAppMessage inserts an inbound message and advances the 24h window clock.
+func (m *Manager) ProcessIncomingWhatsAppMessage(msg models.Message, isNewConversation bool, inboundAt time.Time) (models.Message, error) {
+ if err := m.uploadMessageAttachments(&msg); err != nil {
+ return models.Message{}, fmt.Errorf("uploading whatsapp attachments: %w", err)
+ }
+
+ if err := m.InsertMessage(&msg); err != nil {
+ return models.Message{}, err
+ }
+
+ if err := m.UpdateConversationLastInboundAt(msg.ConversationID, inboundAt); err != nil {
+ m.lo.Error("error updating last_inbound_at", "conversation_id", msg.ConversationID, "error", err)
+ }
+
+ if err := m.ProcessIncomingMessageHooks(msg.ConversationUUID, isNewConversation); err != nil {
+ m.lo.Error("error processing incoming message hooks", "conversation_uuid", msg.ConversationUUID, "error", err)
+ }
+
+ return msg, nil
+}
+
// MessageExists checks if a message with the given messageID exists.
func (m *Manager) MessageExists(messageID string) (bool, error) {
_, err := m.messageExistsBySourceID([]string{messageID})
@@ -1174,7 +1291,7 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".")
if slices.Contains(image.Exts, attachmentExt) && image.IsImageByContent(bytes.NewReader(attachment.Content)) {
if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil {
- m.lo.Error("error uploading thumbnail", "error", err)
+ m.lo.Warn("skipping thumbnail, unsupported image format", "error", err)
}
}
diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go
index ee8d540e8..6bf71c4d6 100644
--- a/internal/conversation/models/models.go
+++ b/internal/conversation/models/models.go
@@ -54,6 +54,7 @@ var (
ActivityTagRemoved = "tag_removed"
ActivitySLASet = "sla_set"
ActivityParticipantAdded = "participant_added"
+ ActivityCSATNotSent = "csat_not_sent"
ContentTypeText = "text"
ContentTypeHTML = "html"
@@ -125,6 +126,7 @@ type ConversationListItem struct {
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
+ LastResolvedAt null.Time `db:"last_resolved_at" json:"last_resolved_at"`
Subject null.String `db:"subject" json:"subject"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
@@ -166,6 +168,7 @@ type Conversation struct {
InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
+ LastResolvedAt null.Time `db:"last_resolved_at" json:"last_resolved_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
@@ -202,6 +205,8 @@ type Conversation struct {
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
LastContinuityEmailSentAt null.Time `db:"last_continuity_email_sent_at" json:"-"`
+ LastInboundAt null.Time `db:"last_inbound_at" json:"last_inbound_at"`
+ ContactLastInboundAt null.Time `db:"contact_last_inbound_at" json:"contact_last_inbound_at"`
CSATRating null.Int `db:"csat_rating" json:"csat_rating"`
CSATFeedback null.String `db:"csat_feedback" json:"csat_feedback"`
CSATRespondedAt null.Time `db:"csat_responded_at" json:"csat_responded_at"`
@@ -226,6 +231,8 @@ type ConversationContact struct {
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
ExternalUserID null.String `db:"external_user_id" json:"external_user_id"`
+
+ ChannelIdentities umodels.ChannelIdentities `db:"channel_identities" json:"channel_identities,omitempty"`
}
func (c *ConversationContact) FullName() string {
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index 99a55f927..deb84f8bf 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -55,6 +55,7 @@ SELECT
conversations.first_reply_at,
conversations.last_reply_at,
conversations.resolved_at,
+ conversations.last_resolved_at,
conversations.subject,
conversations.last_message,
conversations.last_message_at,
@@ -186,6 +187,7 @@ SELECT
c.updated_at,
c.closed_at,
c.resolved_at,
+ c.last_resolved_at,
c.inbox_id,
inb.name as inbox_name,
COALESCE(inb.from, '') as inbox_mail,
@@ -241,12 +243,16 @@ SELECT
ct.last_active_at as "contact.last_active_at",
ct.last_login_at as "contact.last_login_at",
ct.external_user_id as "contact.external_user_id",
+ (SELECT json_agg(json_build_object('channel', cci.channel, 'identifier', cci.identifier))
+ FROM contact_channel_identities cci WHERE cci.contact_id = ct.id) as "contact.channel_identities",
as_latest.first_response_deadline_at,
as_latest.resolution_deadline_at,
as_latest.id as applied_sla_id,
nxt_resp_event.deadline_at AS next_response_deadline_at,
nxt_resp_event.met_at as next_response_met_at,
c.last_continuity_email_sent_at,
+ c.last_inbound_at,
+ (SELECT MAX(c2.last_inbound_at) FROM conversations c2 WHERE c2.contact_id = c.contact_id AND c2.inbox_id = c.inbox_id) AS contact_last_inbound_at,
csat.rating as csat_rating,
csat.feedback as csat_feedback,
csat.response_timestamp as csat_responded_at
@@ -427,6 +433,9 @@ LIMIT 200;
-- name: get-conversation-uuid
SELECT uuid from conversations where id = $1;
+-- name: get-conversation-inbox-contact
+SELECT inbox_id, contact_id FROM conversations WHERE uuid = $1;
+
-- name: update-conversation-assigned-user
UPDATE conversations
SET assigned_user_id = $2,
@@ -457,11 +466,12 @@ WITH new_status AS (
SELECT id, category FROM conversation_statuses WHERE name = $2
)
UPDATE conversations
-SET status_id = (SELECT id FROM new_status),
- resolved_at = COALESCE(resolved_at, CASE WHEN (SELECT category FROM new_status) = 'resolved' THEN NOW() END),
- closed_at = COALESCE(closed_at, CASE WHEN $2 = 'Closed' THEN NOW() END),
- snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE NULL END,
- updated_at = NOW()
+SET status_id = (SELECT id FROM new_status),
+ resolved_at = COALESCE(resolved_at, CASE WHEN (SELECT category FROM new_status) = 'resolved' THEN NOW() END),
+ last_resolved_at = CASE WHEN (SELECT category FROM new_status) = 'resolved' THEN NOW() ELSE last_resolved_at END,
+ closed_at = COALESCE(closed_at, CASE WHEN $2 = 'Closed' THEN NOW() END),
+ snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE NULL END,
+ updated_at = NOW()
WHERE uuid = $1;
-- name: get-user-active-conversations-count
@@ -854,11 +864,106 @@ FROM conversation_messages
WHERE source_id = ANY($1::text []);
-- name: update-message-status
-update conversation_messages set status = $1, updated_at = NOW() where uuid = $2;
+UPDATE conversation_messages SET status = $1::message_status, updated_at = NOW()
+WHERE uuid = $2 AND NOT ($1 = 'sent' AND status = 'failed');
+
+-- name: mark-message-pending-for-retry
+-- Keeping the old wamid or provider_status would let a late webhook re-fail the retried row.
+UPDATE conversation_messages m
+SET status = 'pending',
+ source_id = CASE WHEN inb.channel = 'whatsapp' THEN NULL ELSE m.source_id END,
+ meta = COALESCE(m.meta, '{}'::jsonb)
+ - 'provider_status' - 'provider_status_updated_at' - 'provider_sent_at'
+ - 'provider_delivered_at' - 'provider_read_at' - 'provider_failed_at'
+ - 'provider_failure_reason',
+ updated_at = NOW()
+FROM conversations c
+JOIN inboxes inb ON inb.id = c.inbox_id
+WHERE m.uuid = $1 AND c.id = m.conversation_id AND m.status IN ('failed', 'sent');
-- name: update-message-source-id
UPDATE conversation_messages SET source_id = $1 WHERE id = $2;
+-- name: update-message-source-id-by-uuid
+UPDATE conversation_messages SET source_id = $2, updated_at = NOW() WHERE uuid = $1;
+
+-- name: merge-message-meta-by-uuid
+UPDATE conversation_messages m
+SET meta = COALESCE(m.meta, '{}'::jsonb) || $2::jsonb,
+ updated_at = NOW()
+FROM conversations c
+WHERE m.uuid = $1
+ AND c.id = m.conversation_id
+RETURNING m.uuid, c.uuid AS conversation_uuid, m.meta;
+
+-- name: apply-whatsapp-message-status
+-- Meta guard is monotonic (rank order) and sticky on failure; the enum status only guards against un-failing a failed message.
+WITH ranks(status, rank) AS (
+ VALUES ('sent', 1), ('delivered', 2), ('read', 3), ('failed', 4)
+)
+UPDATE conversation_messages m
+SET status = CASE WHEN m.status != 'failed' THEN $2::message_status ELSE m.status END,
+ meta = CASE
+ WHEN COALESCE(m.meta->>'provider_status', '') != 'failed'
+ AND COALESCE(
+ (SELECT rank FROM ranks WHERE status = ($3::jsonb)->>'provider_status'),
+ 0
+ ) >= COALESCE(
+ (SELECT rank FROM ranks WHERE status = m.meta->>'provider_status'),
+ 0
+ )
+ THEN COALESCE(m.meta, '{}'::jsonb) || $3::jsonb
+ ELSE m.meta
+ END,
+ updated_at = NOW()
+FROM conversations c
+WHERE m.source_id = $1
+ AND c.id = m.conversation_id
+RETURNING m.uuid, c.uuid AS conversation_uuid, m.status, m.meta;
+
+-- name: get-whatsapp-read-receipt-target
+SELECT cm.source_id, c.inbox_id
+FROM conversation_messages cm
+JOIN conversations c ON c.id = cm.conversation_id
+JOIN inboxes i ON i.id = c.inbox_id
+WHERE c.uuid = $1
+ AND i.channel = 'whatsapp'
+ AND cm.type = 'incoming'
+ AND COALESCE(cm.source_id, '') != ''
+ AND cm.created_at > COALESCE(
+ (SELECT last_seen_at FROM conversation_last_seen ls
+ WHERE ls.conversation_id = c.id AND ls.user_id = $2),
+ 'epoch'::timestamptz)
+ORDER BY cm.created_at DESC
+LIMIT 1;
+
+-- name: update-conversation-last-inbound-at
+UPDATE conversations SET last_inbound_at = GREATEST(last_inbound_at, $2), updated_at = NOW() WHERE id = $1
+RETURNING contact_id, inbox_id;
+
+-- name: get-contact-window-inbound-at
+SELECT MAX(last_inbound_at) FROM conversations WHERE contact_id = $1 AND inbox_id = $2;
+
+-- name: get-latest-open-conversation-by-contact-inbox
+SELECT id, uuid
+FROM conversations
+WHERE contact_id = $1
+ AND inbox_id = $2
+ AND status_id IN (SELECT id FROM conversation_statuses WHERE category != 'resolved')
+ORDER BY last_interaction_at DESC NULLS LAST, created_at DESC
+LIMIT 1;
+
+-- name: get-latest-reopenable-conversation-by-contact-inbox
+SELECT id, uuid
+FROM conversations
+WHERE contact_id = $1
+ AND inbox_id = $2
+ AND status_id IN (SELECT id FROM conversation_statuses WHERE category = 'resolved')
+ AND last_resolved_at IS NOT NULL
+ AND last_resolved_at >= NOW() - make_interval(hours => $3)
+ORDER BY last_resolved_at DESC
+LIMIT 1;
+
-- name: get-offline-livechat-conversations
SELECT
c.id,
@@ -1024,3 +1129,10 @@ FROM conversations
WHERE contact_id = $1
ORDER BY last_message_at DESC NULLS LAST
LIMIT 200;
+
+-- name: get-conversation-uuids-by-contact-inbox
+SELECT uuid::text
+FROM conversations
+WHERE contact_id = $1 AND inbox_id = $2
+ORDER BY last_message_at DESC NULLS LAST
+LIMIT 200;
diff --git a/internal/conversation/whatsapp.go b/internal/conversation/whatsapp.go
new file mode 100644
index 000000000..edc9db917
--- /dev/null
+++ b/internal/conversation/whatsapp.go
@@ -0,0 +1,435 @@
+package conversation
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/abhinavxd/libredesk/internal/conversation/models"
+ "github.com/abhinavxd/libredesk/internal/countries"
+ "github.com/abhinavxd/libredesk/internal/envelope"
+ whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp"
+ imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
+ "github.com/abhinavxd/libredesk/internal/stringutil"
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
+ "github.com/jmoiron/sqlx"
+)
+
+// WhatsAppWindowDuration is Meta's customer service window for free-form messages.
+const WhatsAppWindowDuration = 24 * time.Hour
+
+// whatsAppMaxTextLength is Meta's cap on a text message body.
+const whatsAppMaxTextLength = 4096
+
+// WhatsAppStatus values mirror Meta's delivery lifecycle, kept in message.meta.
+const (
+ WhatsAppStatusSent = "sent"
+ WhatsAppStatusDelivered = "delivered"
+ WhatsAppStatusRead = "read"
+ WhatsAppStatusFailed = "failed"
+)
+
+// A media header needs a media ID the sender can't supply, so only text (or no) headers can go out.
+var sendableTemplateHeaderTypes = []string{"", "NONE", "TEXT"}
+
+var templatePlaceholderPattern = regexp.MustCompile(`\{\{[A-Za-z0-9_]+\}\}`)
+
+// WhatsAppReadReceiptTarget returns the inbox ID and wamid of the latest unseen inbound message, or empty values when there is nothing to mark read.
+func (m *Manager) WhatsAppReadReceiptTarget(uuid string, userID int) (int, string, error) {
+ var row struct {
+ SourceID string `db:"source_id"`
+ InboxID int `db:"inbox_id"`
+ }
+ if err := m.q.GetWhatsAppReadReceiptTarget.Get(&row, uuid, userID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, "", nil
+ }
+ return 0, "", err
+ }
+ return row.InboxID, row.SourceID, nil
+}
+
+func (m *Manager) ApplyWhatsAppStatus(sourceID, metaStatus string, eventAt time.Time, errorMsg string) error {
+ if sourceID == "" || metaStatus == "" {
+ return nil
+ }
+ if eventAt.IsZero() {
+ eventAt = time.Now().UTC()
+ }
+ ts := eventAt.Format(time.RFC3339)
+
+ patch := map[string]any{
+ "provider_status": metaStatus,
+ "provider_status_updated_at": ts,
+ }
+ switch metaStatus {
+ case WhatsAppStatusSent:
+ patch["provider_sent_at"] = ts
+ case WhatsAppStatusDelivered:
+ patch["provider_delivered_at"] = ts
+ case WhatsAppStatusRead:
+ patch["provider_read_at"] = ts
+ case WhatsAppStatusFailed:
+ patch["provider_failed_at"] = ts
+ if errorMsg != "" {
+ patch["provider_failure_reason"] = errorMsg
+ }
+ }
+ patchBytes, err := json.Marshal(patch)
+ if err != nil {
+ return err
+ }
+
+ // The message_status enum collapses delivered/read into sent; the full lifecycle lives in meta.
+ dbStatus := models.MessageStatusSent
+ if metaStatus == WhatsAppStatusFailed {
+ dbStatus = models.MessageStatusFailed
+ }
+
+ var row struct {
+ UUID string `db:"uuid"`
+ ConversationUUID string `db:"conversation_uuid"`
+ Status string `db:"status"`
+ Meta json.RawMessage `db:"meta"`
+ }
+ if err := m.q.ApplyWhatsAppMessageStatus.Get(&row, sourceID, dbStatus, patchBytes); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("status update for source_id=%s status=%s: %w", sourceID, metaStatus, ErrMessageNotFound)
+ }
+ m.lo.Error("error applying whatsapp message status", "source_id", sourceID, "error", err)
+ return err
+ }
+ m.BroadcastMessageUpdate(row.ConversationUUID, row.UUID, map[string]any{"status": row.Status, "meta": stripCSATUUID(row.Meta)})
+ return nil
+}
+
+func (m *Manager) RecordWhatsAppSendFailure(messageUUID, errorMsg string) error {
+ if messageUUID == "" || errorMsg == "" {
+ return nil
+ }
+ patch := map[string]any{
+ "provider_status": WhatsAppStatusFailed,
+ "provider_failed_at": time.Now().UTC().Format(time.RFC3339),
+ "provider_failure_reason": errorMsg,
+ }
+ return m.mergeWhatsAppMeta(m.q.MergeMessageMetaByUUID, messageUUID, patch)
+}
+
+// mergeWhatsAppMeta is a no-op on an unmatched key.
+func (m *Manager) mergeWhatsAppMeta(stmt *sqlx.Stmt, key string, patch map[string]any) error {
+ patchBytes, err := json.Marshal(patch)
+ if err != nil {
+ return err
+ }
+ var row struct {
+ UUID string `db:"uuid"`
+ ConversationUUID string `db:"conversation_uuid"`
+ Meta json.RawMessage `db:"meta"`
+ }
+ if err := stmt.Get(&row, key, patchBytes); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ m.lo.Error("error merging whatsapp meta", "key", key, "error", err)
+ return err
+ }
+ m.BroadcastMessageUpdate(row.ConversationUUID, row.UUID, map[string]any{"meta": stripCSATUUID(row.Meta)})
+ return nil
+}
+
+// sendWhatsAppCSAT sends CSAT via the reserved template, falls back to a link inside the 24h window, else records a not-sent activity.
+func (m *Manager) sendWhatsAppCSAT(actorUserID int, conversation models.Conversation, csatUUID, csatURL string) error {
+ meta := map[string]any{
+ "is_csat": true,
+ "is_automated": true,
+ "csat_uuid": csatUUID,
+ }
+
+ if m.whatsappTemplate != nil {
+ t, err := m.whatsappTemplate.GetApproved(conversation.InboxID, wtmodels.CSATTemplateName(conversation.InboxID), m.csatTemplateLanguage(conversation.InboxID))
+ if err == nil {
+ meta["whatsapp_template_id"] = t.ID
+ meta["whatsapp_template_params"] = map[string]string{"button_url_0": csatUUID}
+ if _, err := m.QueueReply(nil, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, "", nil, nil, nil, meta); err != nil {
+ m.lo.Error("error sending whatsapp CSAT template", "conversation_uuid", conversation.UUID, "error", err)
+ return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return nil
+ }
+ }
+
+ if m.whatsAppWindowOpen(conversation.ContactID, conversation.InboxID) {
+ content := m.i18n.Ts("conversation.whatsapp.csatMessage", "link", csatURL)
+ if m.whatsappTemplate != nil {
+ if tmpl, err := m.whatsappTemplate.GetByName(conversation.InboxID, wtmodels.CSATTemplateName(conversation.InboxID)); err == nil && tmpl.BodyContent != "" {
+ content = tmpl.BodyContent + "\n" + csatURL
+ }
+ }
+ if _, err := m.QueueReply(nil, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, content, nil, nil, nil, meta); err != nil {
+ m.lo.Error("error sending whatsapp CSAT link", "conversation_uuid", conversation.UUID, "error", err)
+ return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return nil
+ }
+
+ actor, err := m.userStore.GetSystemUser()
+ if err != nil {
+ m.lo.Error("error fetching system user for whatsapp CSAT activity", "conversation_uuid", conversation.UUID, "error", err)
+ return nil
+ }
+ return m.InsertConversationActivity(models.ActivityCSATNotSent, conversation.UUID, "", actor)
+}
+
+func (m *Manager) csatTemplateLanguage(inboxID int) string {
+ inb, err := m.inboxStore.GetDBRecord(inboxID)
+ if err != nil {
+ return ""
+ }
+ var cfg whatsappChannel.Config
+ if err := json.Unmarshal(inb.Config, &cfg); err != nil {
+ return ""
+ }
+ return cfg.CSATTemplateLanguage
+}
+
+// whatsAppWindowOpen reports whether the contact is inside Meta's 24h window. Scoped to (contact, inbox), not a single conversation.
+func (m *Manager) whatsAppWindowOpen(contactID, inboxID int) bool {
+ var ts sql.NullTime
+ if err := m.q.GetContactWindowInboundAt.Get(&ts, contactID, inboxID); err != nil {
+ m.lo.Error("error getting contact whatsapp window", "contact_id", contactID, "inbox_id", inboxID, "error", err)
+ return false
+ }
+ return ts.Valid && time.Since(ts.Time) < WhatsAppWindowDuration
+}
+
+// prepareWhatsAppOutbound writes channel fields into metaMap and returns the rendered template body, or free-form content unchanged.
+func (m *Manager) prepareWhatsAppOutbound(inboxRecord imodels.Inbox, conversationUUID string, content string, hasAttachments bool, metaMap map[string]any) (string, error) {
+ var conv struct {
+ InboxID int `db:"inbox_id"`
+ ContactID int `db:"contact_id"`
+ }
+ if err := m.q.GetConversationInboxContact.Get(&conv, conversationUUID); err != nil {
+ m.lo.Error("error fetching conversation inbox and contact", "conversation_uuid", conversationUUID, "error", err)
+ return content, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ if conv.InboxID != inboxRecord.ID {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+
+ // The channel identity is the wa_id Meta routes by; the phone columns are display data an agent may edit freely.
+ toPhone, err := m.userStore.GetChannelIdentity(conv.ContactID, whatsappChannel.ChannelWhatsApp)
+ if err != nil {
+ return content, err
+ }
+ if toPhone == "" {
+ contact, err := m.userStore.Get(conv.ContactID, "", nil)
+ if err != nil {
+ return content, err
+ }
+ if contact.PhoneNumber.String == "" {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.contactNoPhone"), nil)
+ }
+ dialCode := countries.DialCodeForISO(contact.PhoneNumberCountryCode.String)
+ if dialCode == "" {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.contactCountryCodeInvalid"), nil)
+ }
+ toPhone = stringutil.NormalizeWhatsAppPhone(dialCode + contact.PhoneNumber.String)
+ if toPhone == "" {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.contactNoPhone"), nil)
+ }
+ // Link the wa_id now so the contact's reply threads back to this contact instead of forking a duplicate.
+ linkedID, err := m.userStore.LinkChannelIdentity(conv.ContactID, whatsappChannel.ChannelWhatsApp, toPhone)
+ if err != nil {
+ return content, err
+ }
+ if linkedID != conv.ContactID {
+ return content, envelope.NewError(envelope.ConflictError, m.i18n.T("conversation.whatsapp.error.numberLinkedToAnotherContact"), nil)
+ }
+ }
+
+ templateID := extractInt(metaMap, "whatsapp_template_id")
+ templateParams := extractStringMap(metaMap, "whatsapp_template_params")
+
+ send := whatsappChannel.SendMeta{
+ ToPhone: toPhone,
+ }
+
+ rendered := content
+
+ if templateID > 0 {
+ if m.whatsappTemplate == nil {
+ return content, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.whatsapp.error.templateStoreUnavailable"), nil)
+ }
+ t, err := m.whatsappTemplate.GetByID(templateID)
+ if err != nil {
+ return content, err
+ }
+ if t.InboxID != inboxRecord.ID {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.templateWrongInbox"), nil)
+ }
+ if !strings.EqualFold(t.Status, wtmodels.StatusApproved) {
+ return content, envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.templateNotApproved", "status", t.Status), nil)
+ }
+ if t.HeaderType.Valid && !slices.Contains(sendableTemplateHeaderTypes, strings.ToUpper(t.HeaderType.String)) {
+ return content, envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.templateHeaderUnsupported", "type", strings.ToUpper(t.HeaderType.String)), nil)
+ }
+ send.TemplateName = t.Name
+ send.TemplateLanguage = t.Language
+ send.TemplateParams = templateParams
+ send.TemplateBodyContent = t.BodyContent
+ if t.HeaderType.Valid {
+ send.TemplateHeaderType = t.HeaderType.String
+ }
+ if t.HeaderContent.Valid {
+ send.TemplateHeaderContent = t.HeaderContent.String
+ }
+ if len(t.Buttons) > 0 {
+ var btns []whatsapp.TemplateButton
+ if err := json.Unmarshal(t.Buttons, &btns); err == nil {
+ send.TemplateButtons = btns
+ }
+ }
+ if err := m.validateTemplateParams(t, templateParams); err != nil {
+ return content, err
+ }
+ rendered = renderTemplateBody(t.BodyContent, templateParams)
+ } else {
+ if !m.whatsAppWindowOpen(conv.ContactID, conv.InboxID) {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.windowClosed"), nil)
+ }
+ if strings.TrimSpace(content) == "" && !hasAttachments {
+ return content, envelope.NewError(envelope.InputError, m.i18n.T("conversation.whatsapp.error.contentRequired"), nil)
+ }
+ if utf8.RuneCountInString(stringutil.HTML2Text(content)) > whatsAppMaxTextLength {
+ return content, envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.tooLong", "limit", strconv.Itoa(whatsAppMaxTextLength)), nil)
+ }
+ }
+
+ encoded, err := json.Marshal(send)
+ if err != nil {
+ return content, err
+ }
+
+ metaMap["whatsapp"] = json.RawMessage(encoded)
+ return rendered, nil
+}
+
+// validateTemplateParams rejects unfilled body and text-header placeholders locally, ahead of Meta's opaque parameter-mismatch error.
+func (m *Manager) validateTemplateParams(t wtmodels.Template, params map[string]string) error {
+ for _, key := range whatsapp.OrderedPlaceholders(t.BodyContent) {
+ if strings.TrimSpace(params["body:"+key]) == "" {
+ return envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.missingBodyParam", "placeholder", "{{"+key+"}}"), nil)
+ }
+ }
+ if t.HeaderType.Valid && strings.EqualFold(t.HeaderType.String, "TEXT") {
+ for _, key := range whatsapp.OrderedPlaceholders(t.HeaderContent.String) {
+ if strings.TrimSpace(params["header:"+key]) == "" {
+ return envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.missingHeaderParam", "placeholder", "{{"+key+"}}"), nil)
+ }
+ }
+ }
+ if len(t.Buttons) > 0 {
+ var btns []whatsapp.TemplateButton
+ if err := json.Unmarshal(t.Buttons, &btns); err == nil {
+ for i, b := range btns {
+ if !strings.EqualFold(b.Type, "URL") || len(whatsapp.OrderedPlaceholders(b.URL)) == 0 {
+ continue
+ }
+ if strings.TrimSpace(params["button_url_"+strconv.Itoa(i)]) == "" {
+ return envelope.NewError(envelope.InputError, m.i18n.Ts("conversation.whatsapp.error.missingButtonParam", "button", b.Text), nil)
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// renderTemplateBody fills {{name}} placeholders from "body:"+name params; unmatched ones stay verbatim so missing params show in the timeline.
+func renderTemplateBody(body string, params map[string]string) string {
+ if body == "" || len(params) == 0 {
+ return body
+ }
+ return templatePlaceholderPattern.ReplaceAllStringFunc(body, func(match string) string {
+ name := match[2 : len(match)-2]
+ if v, ok := params["body:"+name]; ok {
+ return v
+ }
+ return match
+ })
+}
+
+// extractInt pulls an int out of a meta map regardless of the JSON decoder's numeric type.
+func extractInt(m map[string]any, key string) int {
+ switch v := m[key].(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ case json.Number:
+ n, _ := v.Int64()
+ return int(n)
+ }
+ return 0
+}
+
+// extractStringMap pulls a string map out of a meta map, tolerating both map[string]string and decoded map[string]any.
+func extractStringMap(m map[string]any, key string) map[string]string {
+ switch raw := m[key].(type) {
+ case map[string]string:
+ if len(raw) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(raw))
+ maps.Copy(out, raw)
+ return out
+ case map[string]any:
+ if len(raw) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(raw))
+ for k, v := range raw {
+ switch t := v.(type) {
+ case string:
+ out[k] = t
+ case json.Number:
+ out[k] = t.String()
+ case float64:
+ out[k] = fmt.Sprintf("%v", t)
+ case bool:
+ out[k] = fmt.Sprintf("%v", t)
+ }
+ }
+ return out
+ }
+ return nil
+}
+
+func stripCSATUUID(meta json.RawMessage) json.RawMessage {
+ if len(meta) == 0 {
+ return meta
+ }
+ var m map[string]any
+ if err := json.Unmarshal(meta, &m); err != nil {
+ return meta
+ }
+ if _, ok := m["csat_uuid"]; !ok {
+ return meta
+ }
+ delete(m, "csat_uuid")
+ stripped, err := json.Marshal(m)
+ if err != nil {
+ return meta
+ }
+ return stripped
+}
diff --git a/internal/conversation/whatsapp_test.go b/internal/conversation/whatsapp_test.go
new file mode 100644
index 000000000..86adbb74f
--- /dev/null
+++ b/internal/conversation/whatsapp_test.go
@@ -0,0 +1,244 @@
+package conversation
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
+ "github.com/knadh/go-i18n"
+ "github.com/volatiletech/null/v9"
+)
+
+func TestRenderTemplateBody(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ params map[string]string
+ want string
+ }{
+ {
+ name: "named placeholders",
+ body: "Hi {{name}}, order {{order_id}} is {{status}}.",
+ params: map[string]string{"body:name": "Ravi", "body:order_id": "A1", "body:status": "shipped"},
+ want: "Hi Ravi, order A1 is shipped.",
+ },
+ {
+ name: "positional placeholders",
+ body: "Hi {{1}}, order {{2}}.",
+ params: map[string]string{"body:1": "Ravi", "body:2": "A1"},
+ want: "Hi Ravi, order A1.",
+ },
+ {
+ name: "header params never fill the body",
+ body: "Order {{order_id}}",
+ params: map[string]string{"header:order_id": "A1"},
+ want: "Order {{order_id}}",
+ },
+ {
+ name: "unmatched placeholder stays verbatim",
+ body: "Hi {{name}}",
+ params: map[string]string{"body:other": "x"},
+ want: "Hi {{name}}",
+ },
+ {
+ name: "no params",
+ body: "Hi {{name}}",
+ params: nil,
+ want: "Hi {{name}}",
+ },
+ {
+ name: "repeated placeholder fills every occurrence",
+ body: "{{name}} and {{name}}",
+ params: map[string]string{"body:name": "Ravi"},
+ want: "Ravi and Ravi",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := renderTemplateBody(tc.body, tc.params); got != tc.want {
+ t.Fatalf("expected %q, got %q", tc.want, got)
+ }
+ })
+ }
+}
+
+func TestValidateTemplateParams(t *testing.T) {
+ urlButton, _ := json.Marshal([]map[string]any{{"type": "URL", "text": "Track order", "url": "https://x.test/{{1}}"}})
+ staticButton, _ := json.Marshal([]map[string]any{{"type": "URL", "text": "Home", "url": "https://x.test/"}})
+
+ base := wtmodels.Template{BodyContent: "Hi {{name}}, order {{order_id}}."}
+ withHeader := wtmodels.Template{
+ BodyContent: "Hi {{name}}",
+ HeaderType: null.StringFrom("TEXT"),
+ HeaderContent: null.StringFrom("Order {{order_id}}"),
+ }
+
+ tests := []struct {
+ name string
+ template wtmodels.Template
+ params map[string]string
+ wantMatch string
+ }{
+ {
+ name: "all body params filled",
+ template: base,
+ params: map[string]string{"body:name": "Ravi", "body:order_id": "A1"},
+ },
+ {
+ name: "missing body param",
+ template: base,
+ params: map[string]string{"body:name": "Ravi"},
+ wantMatch: "order_id",
+ },
+ {
+ name: "blank body param",
+ template: base,
+ params: map[string]string{"body:name": "Ravi", "body:order_id": " "},
+ wantMatch: "order_id",
+ },
+ {
+ name: "missing text header param",
+ template: withHeader,
+ params: map[string]string{"body:name": "Ravi"},
+ wantMatch: "header's {{order_id}}",
+ },
+ {
+ name: "header param filled",
+ template: withHeader,
+ params: map[string]string{"body:name": "Ravi", "header:order_id": "A1"},
+ },
+ {
+ name: "missing url button param",
+ template: wtmodels.Template{BodyContent: "Hi", Buttons: urlButton},
+ params: nil,
+ wantMatch: "Track order",
+ },
+ {
+ name: "url button param filled",
+ template: wtmodels.Template{BodyContent: "Hi", Buttons: urlButton},
+ params: map[string]string{"button_url_0": "A1"},
+ },
+ {
+ name: "static url button needs no param",
+ template: wtmodels.Template{BodyContent: "Hi", Buttons: staticButton},
+ params: nil,
+ },
+ {
+ name: "media header needs no param",
+ template: wtmodels.Template{BodyContent: "Hi", HeaderType: null.StringFrom("IMAGE")},
+ params: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := testManager(t).validateTemplateParams(tc.template, tc.params)
+ if tc.wantMatch == "" {
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("expected an error mentioning %q", tc.wantMatch)
+ }
+ if !strings.Contains(err.Error(), tc.wantMatch) {
+ t.Fatalf("expected the error to mention %q, got %q", tc.wantMatch, err.Error())
+ }
+ })
+ }
+}
+
+func TestExtractInt(t *testing.T) {
+ tests := []struct {
+ name string
+ meta map[string]any
+ want int
+ }{
+ {"int", map[string]any{"id": 7}, 7},
+ {"int64", map[string]any{"id": int64(7)}, 7},
+ {"float64 from json", map[string]any{"id": float64(7)}, 7},
+ {"json number", map[string]any{"id": json.Number("7")}, 7},
+ {"string is not a number", map[string]any{"id": "7"}, 0},
+ {"absent", map[string]any{}, 0},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := extractInt(tc.meta, "id"); got != tc.want {
+ t.Fatalf("expected %d, got %d", tc.want, got)
+ }
+ })
+ }
+}
+
+func TestExtractStringMap(t *testing.T) {
+ t.Run("typed map", func(t *testing.T) {
+ got := extractStringMap(map[string]any{"p": map[string]string{"body:name": "Ravi"}}, "p")
+ if got["body:name"] != "Ravi" {
+ t.Fatalf("unexpected params: %+v", got)
+ }
+ })
+
+ t.Run("decoded map coerces scalars", func(t *testing.T) {
+ got := extractStringMap(map[string]any{"p": map[string]any{
+ "body:name": "Ravi",
+ "body:count": json.Number("2"),
+ "body:flag": true,
+ }}, "p")
+ if got["body:name"] != "Ravi" || got["body:count"] != "2" || got["body:flag"] != "true" {
+ t.Fatalf("unexpected params: %+v", got)
+ }
+ })
+
+ t.Run("empty and missing", func(t *testing.T) {
+ if got := extractStringMap(map[string]any{"p": map[string]any{}}, "p"); got != nil {
+ t.Fatalf("expected nil for an empty map, got %+v", got)
+ }
+ if got := extractStringMap(map[string]any{}, "p"); got != nil {
+ t.Fatalf("expected nil for a missing key, got %+v", got)
+ }
+ })
+}
+
+func TestStripCSATUUID(t *testing.T) {
+ stripped := stripCSATUUID(json.RawMessage(`{"is_csat":true,"csat_uuid":"secret-uuid"}`))
+ var out map[string]any
+ if err := json.Unmarshal(stripped, &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if _, ok := out["csat_uuid"]; ok {
+ t.Fatal("csat_uuid must not reach the websocket payload")
+ }
+ if out["is_csat"] != true {
+ t.Fatalf("expected the rest of the meta to survive, got %+v", out)
+ }
+
+ same := json.RawMessage(`{"is_csat":true}`)
+ if string(stripCSATUUID(same)) != string(same) {
+ t.Fatal("meta without a csat_uuid must pass through unchanged")
+ }
+ if got := stripCSATUUID(nil); got != nil {
+ t.Fatalf("expected nil meta to pass through, got %q", got)
+ }
+ malformed := json.RawMessage(`not json`)
+ if string(stripCSATUUID(malformed)) != string(malformed) {
+ t.Fatal("malformed meta must pass through unchanged")
+ }
+}
+
+// testManager carries the real language file, so a renamed i18n key fails the test.
+func testManager(t *testing.T) *Manager {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "i18n", "en-US.json"))
+ if err != nil {
+ t.Fatalf("reading the language file: %v", err)
+ }
+ lang, err := i18n.New(raw)
+ if err != nil {
+ t.Fatalf("loading i18n: %v", err)
+ }
+ return &Manager{i18n: lang}
+}
diff --git a/internal/countries/countries.go b/internal/countries/countries.go
new file mode 100644
index 000000000..8d5d18991
--- /dev/null
+++ b/internal/countries/countries.go
@@ -0,0 +1,41 @@
+package countries
+
+import (
+ _ "embed"
+ "encoding/json"
+ "strings"
+)
+
+//go:embed countries.json
+var countriesJSON []byte
+
+var dialDigitsReplacer = strings.NewReplacer("+", "", "-", "")
+
+var dialCodeByISO = buildDialCodes()
+
+// DialCodeForISO returns the dialing-code digits for an ISO-2 country, "" when unknown.
+func DialCodeForISO(iso string) string {
+ return dialCodeByISO[iso]
+}
+
+func buildDialCodes() map[string]string {
+ var list []struct {
+ CallingCode string `json:"calling_code"`
+ ISO2 string `json:"iso_2"`
+ }
+ if err := json.Unmarshal(countriesJSON, &list); err != nil {
+ panic("countries: invalid countries.json: " + err.Error())
+ }
+
+ dialByISO := map[string]string{}
+ for _, c := range list {
+ prefix := dialDigitsReplacer.Replace(c.CallingCode)
+ if prefix == "" || c.ISO2 == "" {
+ continue
+ }
+ if existing, ok := dialByISO[c.ISO2]; !ok || len(prefix) < len(existing) {
+ dialByISO[c.ISO2] = prefix
+ }
+ }
+ return dialByISO
+}
diff --git a/internal/countries/countries.json b/internal/countries/countries.json
new file mode 100644
index 000000000..f3a9fc4f5
--- /dev/null
+++ b/internal/countries/countries.json
@@ -0,0 +1,1430 @@
+[
+ {
+ "calling_code": "+93",
+ "name": "Afghanistan",
+ "emoji": "🇦🇫",
+ "iso_2": "AF"
+ },
+ {
+ "calling_code": "+355",
+ "name": "Albania",
+ "emoji": "🇦🇱",
+ "iso_2": "AL"
+ },
+ {
+ "calling_code": "+213",
+ "name": "Algeria",
+ "emoji": "🇩🇿",
+ "iso_2": "DZ"
+ },
+ {
+ "calling_code": "+1-684",
+ "name": "American Samoa",
+ "emoji": "🇦🇸",
+ "iso_2": "AS"
+ },
+ {
+ "calling_code": "+376",
+ "name": "Andorra",
+ "emoji": "🇦🇩",
+ "iso_2": "AD"
+ },
+ {
+ "calling_code": "+244",
+ "name": "Angola",
+ "emoji": "🇦🇴",
+ "iso_2": "AO"
+ },
+ {
+ "calling_code": "+1-264",
+ "name": "Anguilla",
+ "emoji": "🇦🇮",
+ "iso_2": "AI"
+ },
+ {
+ "calling_code": "+1-268",
+ "name": "Antigua and Barbuda",
+ "emoji": "🇦🇬",
+ "iso_2": "AG"
+ },
+ {
+ "calling_code": "+54",
+ "name": "Argentina",
+ "emoji": "🇦🇷",
+ "iso_2": "AR"
+ },
+ {
+ "calling_code": "+374",
+ "name": "Armenia",
+ "emoji": "🇦🇲",
+ "iso_2": "AM"
+ },
+ {
+ "calling_code": "+297",
+ "name": "Aruba",
+ "emoji": "🇦🇼",
+ "iso_2": "AW"
+ },
+ {
+ "calling_code": "+61",
+ "name": "Australia",
+ "emoji": "🇦🇺",
+ "iso_2": "AU"
+ },
+ {
+ "calling_code": "+43",
+ "name": "Austria",
+ "emoji": "🇦🇹",
+ "iso_2": "AT"
+ },
+ {
+ "calling_code": "+994",
+ "name": "Azerbaijan",
+ "emoji": "🇦🇿",
+ "iso_2": "AZ"
+ },
+ {
+ "calling_code": "+1-242",
+ "name": "Bahamas",
+ "emoji": "🇧🇸",
+ "iso_2": "BS"
+ },
+ {
+ "calling_code": "+973",
+ "name": "Bahrain",
+ "emoji": "🇧ðŸ‡",
+ "iso_2": "BH"
+ },
+ {
+ "calling_code": "+880",
+ "name": "Bangladesh",
+ "emoji": "🇧🇩",
+ "iso_2": "BD"
+ },
+ {
+ "calling_code": "+1-246",
+ "name": "Barbados",
+ "emoji": "🇧🇧",
+ "iso_2": "BB"
+ },
+ {
+ "calling_code": "+375",
+ "name": "Belarus",
+ "emoji": "🇧🇾",
+ "iso_2": "BY"
+ },
+ {
+ "calling_code": "+32",
+ "name": "Belgium",
+ "emoji": "🇧🇪",
+ "iso_2": "BE"
+ },
+ {
+ "calling_code": "+501",
+ "name": "Belize",
+ "emoji": "🇧🇿",
+ "iso_2": "BZ"
+ },
+ {
+ "calling_code": "+229",
+ "name": "Benin",
+ "emoji": "🇧🇯",
+ "iso_2": "BJ"
+ },
+ {
+ "calling_code": "+1-441",
+ "name": "Bermuda",
+ "emoji": "🇧🇲",
+ "iso_2": "BM"
+ },
+ {
+ "calling_code": "+975",
+ "name": "Bhutan",
+ "emoji": "🇧🇹",
+ "iso_2": "BT"
+ },
+ {
+ "calling_code": "+591",
+ "name": "Bolivia",
+ "emoji": "🇧🇴",
+ "iso_2": "BO"
+ },
+ {
+ "calling_code": "+387",
+ "name": "Bosnia and Herzegovina",
+ "emoji": "🇧🇦",
+ "iso_2": "BA"
+ },
+ {
+ "calling_code": "+267",
+ "name": "Botswana",
+ "emoji": "🇧🇼",
+ "iso_2": "BW"
+ },
+ {
+ "calling_code": "+55",
+ "name": "Brazil",
+ "emoji": "🇧🇷",
+ "iso_2": "BR"
+ },
+ {
+ "calling_code": "+246",
+ "name": "British Indian Ocean Territory",
+ "emoji": "🇮🇴",
+ "iso_2": "IO"
+ },
+ {
+ "calling_code": "+673",
+ "name": "Brunei",
+ "emoji": "🇧🇳",
+ "iso_2": "BN"
+ },
+ {
+ "calling_code": "+359",
+ "name": "Bulgaria",
+ "emoji": "🇧🇬",
+ "iso_2": "BG"
+ },
+ {
+ "calling_code": "+226",
+ "name": "Burkina Faso",
+ "emoji": "🇧🇫",
+ "iso_2": "BF"
+ },
+ {
+ "calling_code": "+257",
+ "name": "Burundi",
+ "emoji": "🇧🇮",
+ "iso_2": "BI"
+ },
+ {
+ "calling_code": "+855",
+ "name": "Cambodia",
+ "emoji": "🇰ðŸ‡",
+ "iso_2": "KH"
+ },
+ {
+ "calling_code": "+237",
+ "name": "Cameroon",
+ "emoji": "🇨🇲",
+ "iso_2": "CM"
+ },
+ {
+ "calling_code": "+1",
+ "name": "Canada",
+ "emoji": "🇨🇦",
+ "iso_2": "CA"
+ },
+ {
+ "calling_code": "+238",
+ "name": "Cape Verde",
+ "emoji": "🇨🇻",
+ "iso_2": "CV"
+ },
+ {
+ "calling_code": "+1-345",
+ "name": "Cayman Islands",
+ "emoji": "🇰🇾",
+ "iso_2": "KY"
+ },
+ {
+ "calling_code": "+236",
+ "name": "Central African Republic",
+ "emoji": "🇨🇫",
+ "iso_2": "CF"
+ },
+ {
+ "calling_code": "+235",
+ "name": "Chad",
+ "emoji": "🇹🇩",
+ "iso_2": "TD"
+ },
+ {
+ "calling_code": "+56",
+ "name": "Chile",
+ "emoji": "🇨🇱",
+ "iso_2": "CL"
+ },
+ {
+ "calling_code": "+86",
+ "name": "China",
+ "emoji": "🇨🇳",
+ "iso_2": "CN"
+ },
+ {
+ "calling_code": "+61",
+ "name": "Christmas Island",
+ "emoji": "🇨🇽",
+ "iso_2": "CX"
+ },
+ {
+ "calling_code": "+61",
+ "name": "Cocos (Keeling) Islands",
+ "emoji": "🇨🇨",
+ "iso_2": "CC"
+ },
+ {
+ "calling_code": "+57",
+ "name": "Colombia",
+ "emoji": "🇨🇴",
+ "iso_2": "CO"
+ },
+ {
+ "calling_code": "+269",
+ "name": "Comoros",
+ "emoji": "🇰🇲",
+ "iso_2": "KM"
+ },
+ {
+ "calling_code": "+242",
+ "name": "Congo",
+ "emoji": "🇨🇬",
+ "iso_2": "CG"
+ },
+ {
+ "calling_code": "+243",
+ "name": "Congo, Democratic Republic of the",
+ "emoji": "🇨🇩",
+ "iso_2": "CD"
+ },
+ {
+ "calling_code": "+682",
+ "name": "Cook Islands",
+ "emoji": "🇨🇰",
+ "iso_2": "CK"
+ },
+ {
+ "calling_code": "+506",
+ "name": "Costa Rica",
+ "emoji": "🇨🇷",
+ "iso_2": "CR"
+ },
+ {
+ "calling_code": "+225",
+ "name": "Côte d'Ivoire",
+ "emoji": "🇨🇮",
+ "iso_2": "CI"
+ },
+ {
+ "calling_code": "+385",
+ "name": "Croatia",
+ "emoji": "ðŸ‡ðŸ‡·",
+ "iso_2": "HR"
+ },
+ {
+ "calling_code": "+53",
+ "name": "Cuba",
+ "emoji": "🇨🇺",
+ "iso_2": "CU"
+ },
+ {
+ "calling_code": "+599",
+ "name": "Curaçao",
+ "emoji": "🇨🇼",
+ "iso_2": "CW"
+ },
+ {
+ "calling_code": "+357",
+ "name": "Cyprus",
+ "emoji": "🇨🇾",
+ "iso_2": "CY"
+ },
+ {
+ "calling_code": "+420",
+ "name": "Czech Republic",
+ "emoji": "🇨🇿",
+ "iso_2": "CZ"
+ },
+ {
+ "calling_code": "+45",
+ "name": "Denmark",
+ "emoji": "🇩🇰",
+ "iso_2": "DK"
+ },
+ {
+ "calling_code": "+253",
+ "name": "Djibouti",
+ "emoji": "🇩🇯",
+ "iso_2": "DJ"
+ },
+ {
+ "calling_code": "+1-767",
+ "name": "Dominica",
+ "emoji": "🇩🇲",
+ "iso_2": "DM"
+ },
+ {
+ "calling_code": "+1-809",
+ "name": "Dominican Republic",
+ "emoji": "🇩🇴",
+ "iso_2": "DO"
+ },
+ {
+ "calling_code": "+593",
+ "name": "Ecuador",
+ "emoji": "🇪🇨",
+ "iso_2": "EC"
+ },
+ {
+ "calling_code": "+20",
+ "name": "Egypt",
+ "emoji": "🇪🇬",
+ "iso_2": "EG"
+ },
+ {
+ "calling_code": "+503",
+ "name": "El Salvador",
+ "emoji": "🇸🇻",
+ "iso_2": "SV"
+ },
+ {
+ "calling_code": "+240",
+ "name": "Equatorial Guinea",
+ "emoji": "🇬🇶",
+ "iso_2": "GQ"
+ },
+ {
+ "calling_code": "+291",
+ "name": "Eritrea",
+ "emoji": "🇪🇷",
+ "iso_2": "ER"
+ },
+ {
+ "calling_code": "+372",
+ "name": "Estonia",
+ "emoji": "🇪🇪",
+ "iso_2": "EE"
+ },
+ {
+ "calling_code": "+268",
+ "name": "Eswatini",
+ "emoji": "🇸🇿",
+ "iso_2": "SZ"
+ },
+ {
+ "calling_code": "+251",
+ "name": "Ethiopia",
+ "emoji": "🇪🇹",
+ "iso_2": "ET"
+ },
+ {
+ "calling_code": "+500",
+ "name": "Falkland Islands",
+ "emoji": "🇫🇰",
+ "iso_2": "FK"
+ },
+ {
+ "calling_code": "+298",
+ "name": "Faroe Islands",
+ "emoji": "🇫🇴",
+ "iso_2": "FO"
+ },
+ {
+ "calling_code": "+679",
+ "name": "Fiji",
+ "emoji": "🇫🇯",
+ "iso_2": "FJ"
+ },
+ {
+ "calling_code": "+358",
+ "name": "Finland",
+ "emoji": "🇫🇮",
+ "iso_2": "FI"
+ },
+ {
+ "calling_code": "+33",
+ "name": "France",
+ "emoji": "🇫🇷",
+ "iso_2": "FR"
+ },
+ {
+ "calling_code": "+594",
+ "name": "French Guiana",
+ "emoji": "🇬🇫",
+ "iso_2": "GF"
+ },
+ {
+ "calling_code": "+689",
+ "name": "French Polynesia",
+ "emoji": "🇵🇫",
+ "iso_2": "PF"
+ },
+ {
+ "calling_code": "+241",
+ "name": "Gabon",
+ "emoji": "🇬🇦",
+ "iso_2": "GA"
+ },
+ {
+ "calling_code": "+220",
+ "name": "Gambia",
+ "emoji": "🇬🇲",
+ "iso_2": "GM"
+ },
+ {
+ "calling_code": "+995",
+ "name": "Georgia",
+ "emoji": "🇬🇪",
+ "iso_2": "GE"
+ },
+ {
+ "calling_code": "+49",
+ "name": "Germany",
+ "emoji": "🇩🇪",
+ "iso_2": "DE"
+ },
+ {
+ "calling_code": "+233",
+ "name": "Ghana",
+ "emoji": "🇬ðŸ‡",
+ "iso_2": "GH"
+ },
+ {
+ "calling_code": "+350",
+ "name": "Gibraltar",
+ "emoji": "🇬🇮",
+ "iso_2": "GI"
+ },
+ {
+ "calling_code": "+30",
+ "name": "Greece",
+ "emoji": "🇬🇷",
+ "iso_2": "GR"
+ },
+ {
+ "calling_code": "+299",
+ "name": "Greenland",
+ "emoji": "🇬🇱",
+ "iso_2": "GL"
+ },
+ {
+ "calling_code": "+1-473",
+ "name": "Grenada",
+ "emoji": "🇬🇩",
+ "iso_2": "GD"
+ },
+ {
+ "calling_code": "+590",
+ "name": "Guadeloupe",
+ "emoji": "🇬🇵",
+ "iso_2": "GP"
+ },
+ {
+ "calling_code": "+1-671",
+ "name": "Guam",
+ "emoji": "🇬🇺",
+ "iso_2": "GU"
+ },
+ {
+ "calling_code": "+502",
+ "name": "Guatemala",
+ "emoji": "🇬🇹",
+ "iso_2": "GT"
+ },
+ {
+ "calling_code": "+44-1481",
+ "name": "Guernsey",
+ "emoji": "🇬🇬",
+ "iso_2": "GG"
+ },
+ {
+ "calling_code": "+224",
+ "name": "Guinea",
+ "emoji": "🇬🇳",
+ "iso_2": "GN"
+ },
+ {
+ "calling_code": "+245",
+ "name": "Guinea-Bissau",
+ "emoji": "🇬🇼",
+ "iso_2": "GW"
+ },
+ {
+ "calling_code": "+592",
+ "name": "Guyana",
+ "emoji": "🇬🇾",
+ "iso_2": "GY"
+ },
+ {
+ "calling_code": "+509",
+ "name": "Haiti",
+ "emoji": "ðŸ‡ðŸ‡¹",
+ "iso_2": "HT"
+ },
+ {
+ "calling_code": "+379",
+ "name": "Vatican City",
+ "emoji": "🇻🇦",
+ "iso_2": "VA"
+ },
+ {
+ "calling_code": "+504",
+ "name": "Honduras",
+ "emoji": "ðŸ‡ðŸ‡³",
+ "iso_2": "HN"
+ },
+ {
+ "calling_code": "+852",
+ "name": "Hong Kong",
+ "emoji": "ðŸ‡ðŸ‡°",
+ "iso_2": "HK"
+ },
+ {
+ "calling_code": "+36",
+ "name": "Hungary",
+ "emoji": "ðŸ‡ðŸ‡º",
+ "iso_2": "HU"
+ },
+ {
+ "calling_code": "+354",
+ "name": "Iceland",
+ "emoji": "🇮🇸",
+ "iso_2": "IS"
+ },
+ {
+ "calling_code": "+91",
+ "name": "India",
+ "emoji": "🇮🇳",
+ "iso_2": "IN"
+ },
+ {
+ "calling_code": "+62",
+ "name": "Indonesia",
+ "emoji": "🇮🇩",
+ "iso_2": "ID"
+ },
+ {
+ "calling_code": "+98",
+ "name": "Iran",
+ "emoji": "🇮🇷",
+ "iso_2": "IR"
+ },
+ {
+ "calling_code": "+964",
+ "name": "Iraq",
+ "emoji": "🇮🇶",
+ "iso_2": "IQ"
+ },
+ {
+ "calling_code": "+353",
+ "name": "Ireland",
+ "emoji": "🇮🇪",
+ "iso_2": "IE"
+ },
+ {
+ "calling_code": "+44-1624",
+ "name": "Isle of Man",
+ "emoji": "🇮🇲",
+ "iso_2": "IM"
+ },
+ {
+ "calling_code": "+972",
+ "name": "Israel",
+ "emoji": "🇮🇱",
+ "iso_2": "IL"
+ },
+ {
+ "calling_code": "+39",
+ "name": "Italy",
+ "emoji": "🇮🇹",
+ "iso_2": "IT"
+ },
+ {
+ "calling_code": "+1-876",
+ "name": "Jamaica",
+ "emoji": "🇯🇲",
+ "iso_2": "JM"
+ },
+ {
+ "calling_code": "+81",
+ "name": "Japan",
+ "emoji": "🇯🇵",
+ "iso_2": "JP"
+ },
+ {
+ "calling_code": "+44-1534",
+ "name": "Jersey",
+ "emoji": "🇯🇪",
+ "iso_2": "JE"
+ },
+ {
+ "calling_code": "+962",
+ "name": "Jordan",
+ "emoji": "🇯🇴",
+ "iso_2": "JO"
+ },
+ {
+ "calling_code": "+7",
+ "name": "Kazakhstan",
+ "emoji": "🇰🇿",
+ "iso_2": "KZ"
+ },
+ {
+ "calling_code": "+254",
+ "name": "Kenya",
+ "emoji": "🇰🇪",
+ "iso_2": "KE"
+ },
+ {
+ "calling_code": "+686",
+ "name": "Kiribati",
+ "emoji": "🇰🇮",
+ "iso_2": "KI"
+ },
+ {
+ "calling_code": "+383",
+ "name": "Kosovo",
+ "emoji": "🇽🇰",
+ "iso_2": "XK"
+ },
+ {
+ "calling_code": "+965",
+ "name": "Kuwait",
+ "emoji": "🇰🇼",
+ "iso_2": "KW"
+ },
+ {
+ "calling_code": "+996",
+ "name": "Kyrgyzstan",
+ "emoji": "🇰🇬",
+ "iso_2": "KG"
+ },
+ {
+ "calling_code": "+856",
+ "name": "Laos",
+ "emoji": "🇱🇦",
+ "iso_2": "LA"
+ },
+ {
+ "calling_code": "+371",
+ "name": "Latvia",
+ "emoji": "🇱🇻",
+ "iso_2": "LV"
+ },
+ {
+ "calling_code": "+961",
+ "name": "Lebanon",
+ "emoji": "🇱🇧",
+ "iso_2": "LB"
+ },
+ {
+ "calling_code": "+266",
+ "name": "Lesotho",
+ "emoji": "🇱🇸",
+ "iso_2": "LS"
+ },
+ {
+ "calling_code": "+231",
+ "name": "Liberia",
+ "emoji": "🇱🇷",
+ "iso_2": "LR"
+ },
+ {
+ "calling_code": "+218",
+ "name": "Libya",
+ "emoji": "🇱🇾",
+ "iso_2": "LY"
+ },
+ {
+ "calling_code": "+423",
+ "name": "Liechtenstein",
+ "emoji": "🇱🇮",
+ "iso_2": "LI"
+ },
+ {
+ "calling_code": "+370",
+ "name": "Lithuania",
+ "emoji": "🇱🇹",
+ "iso_2": "LT"
+ },
+ {
+ "calling_code": "+352",
+ "name": "Luxembourg",
+ "emoji": "🇱🇺",
+ "iso_2": "LU"
+ },
+ {
+ "calling_code": "+853",
+ "name": "Macao",
+ "emoji": "🇲🇴",
+ "iso_2": "MO"
+ },
+ {
+ "calling_code": "+389",
+ "name": "North Macedonia",
+ "emoji": "🇲🇰",
+ "iso_2": "MK"
+ },
+ {
+ "calling_code": "+261",
+ "name": "Madagascar",
+ "emoji": "🇲🇬",
+ "iso_2": "MG"
+ },
+ {
+ "calling_code": "+265",
+ "name": "Malawi",
+ "emoji": "🇲🇼",
+ "iso_2": "MW"
+ },
+ {
+ "calling_code": "+60",
+ "name": "Malaysia",
+ "emoji": "🇲🇾",
+ "iso_2": "MY"
+ },
+ {
+ "calling_code": "+960",
+ "name": "Maldives",
+ "emoji": "🇲🇻",
+ "iso_2": "MV"
+ },
+ {
+ "calling_code": "+223",
+ "name": "Mali",
+ "emoji": "🇲🇱",
+ "iso_2": "ML"
+ },
+ {
+ "calling_code": "+356",
+ "name": "Malta",
+ "emoji": "🇲🇹",
+ "iso_2": "MT"
+ },
+ {
+ "calling_code": "+692",
+ "name": "Marshall Islands",
+ "emoji": "🇲ðŸ‡",
+ "iso_2": "MH"
+ },
+ {
+ "calling_code": "+596",
+ "name": "Martinique",
+ "emoji": "🇲🇶",
+ "iso_2": "MQ"
+ },
+ {
+ "calling_code": "+222",
+ "name": "Mauritania",
+ "emoji": "🇲🇷",
+ "iso_2": "MR"
+ },
+ {
+ "calling_code": "+230",
+ "name": "Mauritius",
+ "emoji": "🇲🇺",
+ "iso_2": "MU"
+ },
+ {
+ "calling_code": "+262",
+ "name": "Mayotte",
+ "emoji": "🇾🇹",
+ "iso_2": "YT"
+ },
+ {
+ "calling_code": "+52",
+ "name": "Mexico",
+ "emoji": "🇲🇽",
+ "iso_2": "MX"
+ },
+ {
+ "calling_code": "+691",
+ "name": "Micronesia",
+ "emoji": "🇫🇲",
+ "iso_2": "FM"
+ },
+ {
+ "calling_code": "+373",
+ "name": "Moldova",
+ "emoji": "🇲🇩",
+ "iso_2": "MD"
+ },
+ {
+ "calling_code": "+377",
+ "name": "Monaco",
+ "emoji": "🇲🇨",
+ "iso_2": "MC"
+ },
+ {
+ "calling_code": "+976",
+ "name": "Mongolia",
+ "emoji": "🇲🇳",
+ "iso_2": "MN"
+ },
+ {
+ "calling_code": "+382",
+ "name": "Montenegro",
+ "emoji": "🇲🇪",
+ "iso_2": "ME"
+ },
+ {
+ "calling_code": "+1-664",
+ "name": "Montserrat",
+ "emoji": "🇲🇸",
+ "iso_2": "MS"
+ },
+ {
+ "calling_code": "+212",
+ "name": "Morocco",
+ "emoji": "🇲🇦",
+ "iso_2": "MA"
+ },
+ {
+ "calling_code": "+258",
+ "name": "Mozambique",
+ "emoji": "🇲🇿",
+ "iso_2": "MZ"
+ },
+ {
+ "calling_code": "+95",
+ "name": "Myanmar",
+ "emoji": "🇲🇲",
+ "iso_2": "MM"
+ },
+ {
+ "calling_code": "+264",
+ "name": "Namibia",
+ "emoji": "🇳🇦",
+ "iso_2": "NA"
+ },
+ {
+ "calling_code": "+674",
+ "name": "Nauru",
+ "emoji": "🇳🇷",
+ "iso_2": "NR"
+ },
+ {
+ "calling_code": "+977",
+ "name": "Nepal",
+ "emoji": "🇳🇵",
+ "iso_2": "NP"
+ },
+ {
+ "calling_code": "+31",
+ "name": "Netherlands",
+ "emoji": "🇳🇱",
+ "iso_2": "NL"
+ },
+ {
+ "calling_code": "+687",
+ "name": "New Caledonia",
+ "emoji": "🇳🇨",
+ "iso_2": "NC"
+ },
+ {
+ "calling_code": "+64",
+ "name": "New Zealand",
+ "emoji": "🇳🇿",
+ "iso_2": "NZ"
+ },
+ {
+ "calling_code": "+505",
+ "name": "Nicaragua",
+ "emoji": "🇳🇮",
+ "iso_2": "NI"
+ },
+ {
+ "calling_code": "+227",
+ "name": "Niger",
+ "emoji": "🇳🇪",
+ "iso_2": "NE"
+ },
+ {
+ "calling_code": "+234",
+ "name": "Nigeria",
+ "emoji": "🇳🇬",
+ "iso_2": "NG"
+ },
+ {
+ "calling_code": "+683",
+ "name": "Niue",
+ "emoji": "🇳🇺",
+ "iso_2": "NU"
+ },
+ {
+ "calling_code": "+672",
+ "name": "Norfolk Island",
+ "emoji": "🇳🇫",
+ "iso_2": "NF"
+ },
+ {
+ "calling_code": "+850",
+ "name": "North Korea",
+ "emoji": "🇰🇵",
+ "iso_2": "KP"
+ },
+ {
+ "calling_code": "+47",
+ "name": "Norway",
+ "emoji": "🇳🇴",
+ "iso_2": "NO"
+ },
+ {
+ "calling_code": "+968",
+ "name": "Oman",
+ "emoji": "🇴🇲",
+ "iso_2": "OM"
+ },
+ {
+ "calling_code": "+92",
+ "name": "Pakistan",
+ "emoji": "🇵🇰",
+ "iso_2": "PK"
+ },
+ {
+ "calling_code": "+680",
+ "name": "Palau",
+ "emoji": "🇵🇼",
+ "iso_2": "PW"
+ },
+ {
+ "calling_code": "+970",
+ "name": "Palestine",
+ "emoji": "🇵🇸",
+ "iso_2": "PS"
+ },
+ {
+ "calling_code": "+507",
+ "name": "Panama",
+ "emoji": "🇵🇦",
+ "iso_2": "PA"
+ },
+ {
+ "calling_code": "+675",
+ "name": "Papua New Guinea",
+ "emoji": "🇵🇬",
+ "iso_2": "PG"
+ },
+ {
+ "calling_code": "+595",
+ "name": "Paraguay",
+ "emoji": "🇵🇾",
+ "iso_2": "PY"
+ },
+ {
+ "calling_code": "+51",
+ "name": "Peru",
+ "emoji": "🇵🇪",
+ "iso_2": "PE"
+ },
+ {
+ "calling_code": "+63",
+ "name": "Philippines",
+ "emoji": "🇵ðŸ‡",
+ "iso_2": "PH"
+ },
+ {
+ "calling_code": "+64",
+ "name": "Pitcairn Islands",
+ "emoji": "🇵🇳",
+ "iso_2": "PN"
+ },
+ {
+ "calling_code": "+48",
+ "name": "Poland",
+ "emoji": "🇵🇱",
+ "iso_2": "PL"
+ },
+ {
+ "calling_code": "+351",
+ "name": "Portugal",
+ "emoji": "🇵🇹",
+ "iso_2": "PT"
+ },
+ {
+ "calling_code": "+1-787",
+ "name": "Puerto Rico",
+ "emoji": "🇵🇷",
+ "iso_2": "PR"
+ },
+ {
+ "calling_code": "+974",
+ "name": "Qatar",
+ "emoji": "🇶🇦",
+ "iso_2": "QA"
+ },
+ {
+ "calling_code": "+40",
+ "name": "Romania",
+ "emoji": "🇷🇴",
+ "iso_2": "RO"
+ },
+ {
+ "calling_code": "+7",
+ "name": "Russia",
+ "emoji": "🇷🇺",
+ "iso_2": "RU"
+ },
+ {
+ "calling_code": "+250",
+ "name": "Rwanda",
+ "emoji": "🇷🇼",
+ "iso_2": "RW"
+ },
+ {
+ "calling_code": "+590",
+ "name": "Saint Barthélemy",
+ "emoji": "🇧🇱",
+ "iso_2": "BL"
+ },
+ {
+ "calling_code": "+290",
+ "name": "Saint Helena, Ascension and Tristan da Cunha",
+ "emoji": "🇸ðŸ‡",
+ "iso_2": "SH"
+ },
+ {
+ "calling_code": "+1-869",
+ "name": "Saint Kitts and Nevis",
+ "emoji": "🇰🇳",
+ "iso_2": "KN"
+ },
+ {
+ "calling_code": "+1-758",
+ "name": "Saint Lucia",
+ "emoji": "🇱🇨",
+ "iso_2": "LC"
+ },
+ {
+ "calling_code": "+590",
+ "name": "Saint Martin",
+ "emoji": "🇲🇫",
+ "iso_2": "MF"
+ },
+ {
+ "calling_code": "+508",
+ "name": "Saint Pierre and Miquelon",
+ "emoji": "🇵🇲",
+ "iso_2": "PM"
+ },
+ {
+ "calling_code": "+1-784",
+ "name": "Saint Vincent and the Grenadines",
+ "emoji": "🇻🇨",
+ "iso_2": "VC"
+ },
+ {
+ "calling_code": "+685",
+ "name": "Samoa",
+ "emoji": "🇼🇸",
+ "iso_2": "WS"
+ },
+ {
+ "calling_code": "+378",
+ "name": "San Marino",
+ "emoji": "🇸🇲",
+ "iso_2": "SM"
+ },
+ {
+ "calling_code": "+239",
+ "name": "Sao Tome and Principe",
+ "emoji": "🇸🇹",
+ "iso_2": "ST"
+ },
+ {
+ "calling_code": "+966",
+ "name": "Saudi Arabia",
+ "emoji": "🇸🇦",
+ "iso_2": "SA"
+ },
+ {
+ "calling_code": "+221",
+ "name": "Senegal",
+ "emoji": "🇸🇳",
+ "iso_2": "SN"
+ },
+ {
+ "calling_code": "+381",
+ "name": "Serbia",
+ "emoji": "🇷🇸",
+ "iso_2": "RS"
+ },
+ {
+ "calling_code": "+248",
+ "name": "Seychelles",
+ "emoji": "🇸🇨",
+ "iso_2": "SC"
+ },
+ {
+ "calling_code": "+232",
+ "name": "Sierra Leone",
+ "emoji": "🇸🇱",
+ "iso_2": "SL"
+ },
+ {
+ "calling_code": "+65",
+ "name": "Singapore",
+ "emoji": "🇸🇬",
+ "iso_2": "SG"
+ },
+ {
+ "calling_code": "+1-721",
+ "name": "Sint Maarten",
+ "emoji": "🇸🇽",
+ "iso_2": "SX"
+ },
+ {
+ "calling_code": "+421",
+ "name": "Slovakia",
+ "emoji": "🇸🇰",
+ "iso_2": "SK"
+ },
+ {
+ "calling_code": "+386",
+ "name": "Slovenia",
+ "emoji": "🇸🇮",
+ "iso_2": "SI"
+ },
+ {
+ "calling_code": "+677",
+ "name": "Solomon Islands",
+ "emoji": "🇸🇧",
+ "iso_2": "SB"
+ },
+ {
+ "calling_code": "+252",
+ "name": "Somalia",
+ "emoji": "🇸🇴",
+ "iso_2": "SO"
+ },
+ {
+ "calling_code": "+27",
+ "name": "South Africa",
+ "emoji": "🇿🇦",
+ "iso_2": "ZA"
+ },
+ {
+ "calling_code": "+82",
+ "name": "South Korea",
+ "emoji": "🇰🇷",
+ "iso_2": "KR"
+ },
+ {
+ "calling_code": "+211",
+ "name": "South Sudan",
+ "emoji": "🇸🇸",
+ "iso_2": "SS"
+ },
+ {
+ "calling_code": "+34",
+ "name": "Spain",
+ "emoji": "🇪🇸",
+ "iso_2": "ES"
+ },
+ {
+ "calling_code": "+94",
+ "name": "Sri Lanka",
+ "emoji": "🇱🇰",
+ "iso_2": "LK"
+ },
+ {
+ "calling_code": "+249",
+ "name": "Sudan",
+ "emoji": "🇸🇩",
+ "iso_2": "SD"
+ },
+ {
+ "calling_code": "+597",
+ "name": "Suriname",
+ "emoji": "🇸🇷",
+ "iso_2": "SR"
+ },
+ {
+ "calling_code": "+47",
+ "name": "Svalbard and Jan Mayen",
+ "emoji": "🇸🇯",
+ "iso_2": "SJ"
+ },
+ {
+ "calling_code": "+46",
+ "name": "Sweden",
+ "emoji": "🇸🇪",
+ "iso_2": "SE"
+ },
+ {
+ "calling_code": "+41",
+ "name": "Switzerland",
+ "emoji": "🇨ðŸ‡",
+ "iso_2": "CH"
+ },
+ {
+ "calling_code": "+963",
+ "name": "Syria",
+ "emoji": "🇸🇾",
+ "iso_2": "SY"
+ },
+ {
+ "calling_code": "+886",
+ "name": "Taiwan",
+ "emoji": "🇹🇼",
+ "iso_2": "TW"
+ },
+ {
+ "calling_code": "+992",
+ "name": "Tajikistan",
+ "emoji": "🇹🇯",
+ "iso_2": "TJ"
+ },
+ {
+ "calling_code": "+255",
+ "name": "Tanzania",
+ "emoji": "🇹🇿",
+ "iso_2": "TZ"
+ },
+ {
+ "calling_code": "+66",
+ "name": "Thailand",
+ "emoji": "🇹ðŸ‡",
+ "iso_2": "TH"
+ },
+ {
+ "calling_code": "+670",
+ "name": "Timor-Leste",
+ "emoji": "🇹🇱",
+ "iso_2": "TL"
+ },
+ {
+ "calling_code": "+228",
+ "name": "Togo",
+ "emoji": "🇹🇬",
+ "iso_2": "TG"
+ },
+ {
+ "calling_code": "+690",
+ "name": "Tokelau",
+ "emoji": "🇹🇰",
+ "iso_2": "TK"
+ },
+ {
+ "calling_code": "+676",
+ "name": "Tonga",
+ "emoji": "🇹🇴",
+ "iso_2": "TO"
+ },
+ {
+ "calling_code": "+1-868",
+ "name": "Trinidad and Tobago",
+ "emoji": "🇹🇹",
+ "iso_2": "TT"
+ },
+ {
+ "calling_code": "+216",
+ "name": "Tunisia",
+ "emoji": "🇹🇳",
+ "iso_2": "TN"
+ },
+ {
+ "calling_code": "+90",
+ "name": "Turkey",
+ "emoji": "🇹🇷",
+ "iso_2": "TR"
+ },
+ {
+ "calling_code": "+993",
+ "name": "Turkmenistan",
+ "emoji": "🇹🇲",
+ "iso_2": "TM"
+ },
+ {
+ "calling_code": "+1-649",
+ "name": "Turks and Caicos Islands",
+ "emoji": "🇹🇨",
+ "iso_2": "TC"
+ },
+ {
+ "calling_code": "+688",
+ "name": "Tuvalu",
+ "emoji": "🇹🇻",
+ "iso_2": "TV"
+ },
+ {
+ "calling_code": "+256",
+ "name": "Uganda",
+ "emoji": "🇺🇬",
+ "iso_2": "UG"
+ },
+ {
+ "calling_code": "+380",
+ "name": "Ukraine",
+ "emoji": "🇺🇦",
+ "iso_2": "UA"
+ },
+ {
+ "calling_code": "+971",
+ "name": "United Arab Emirates",
+ "emoji": "🇦🇪",
+ "iso_2": "AE"
+ },
+ {
+ "calling_code": "+44",
+ "name": "United Kingdom",
+ "emoji": "🇬🇧",
+ "iso_2": "GB"
+ },
+ {
+ "calling_code": "+1",
+ "name": "United States",
+ "emoji": "🇺🇸",
+ "iso_2": "US"
+ },
+ {
+ "calling_code": "+598",
+ "name": "Uruguay",
+ "emoji": "🇺🇾",
+ "iso_2": "UY"
+ },
+ {
+ "calling_code": "+998",
+ "name": "Uzbekistan",
+ "emoji": "🇺🇿",
+ "iso_2": "UZ"
+ },
+ {
+ "calling_code": "+678",
+ "name": "Vanuatu",
+ "emoji": "🇻🇺",
+ "iso_2": "VU"
+ },
+ {
+ "calling_code": "+58",
+ "name": "Venezuela",
+ "emoji": "🇻🇪",
+ "iso_2": "VE"
+ },
+ {
+ "calling_code": "+84",
+ "name": "Vietnam",
+ "emoji": "🇻🇳",
+ "iso_2": "VN"
+ },
+ {
+ "calling_code": "+681",
+ "name": "Wallis and Futuna",
+ "emoji": "🇼🇫",
+ "iso_2": "WF"
+ },
+ {
+ "calling_code": "+212",
+ "name": "Western Sahara",
+ "emoji": "🇪ðŸ‡",
+ "iso_2": "EH"
+ },
+ {
+ "calling_code": "+967",
+ "name": "Yemen",
+ "emoji": "🇾🇪",
+ "iso_2": "YE"
+ },
+ {
+ "calling_code": "+260",
+ "name": "Zambia",
+ "emoji": "🇿🇲",
+ "iso_2": "ZM"
+ },
+ {
+ "calling_code": "+263",
+ "name": "Zimbabwe",
+ "emoji": "🇿🇼",
+ "iso_2": "ZW"
+ }
+]
diff --git a/internal/countries/countries_test.go b/internal/countries/countries_test.go
new file mode 100644
index 000000000..4cd127ed8
--- /dev/null
+++ b/internal/countries/countries_test.go
@@ -0,0 +1,30 @@
+package countries
+
+import "testing"
+
+func TestDialCodeForISO(t *testing.T) {
+ tests := []struct {
+ iso string
+ want string
+ }{
+ {iso: "IN", want: "91"},
+ {iso: "US", want: "1"},
+ {iso: "GB", want: "44"},
+ {iso: "AE", want: "971"},
+ {iso: "AS", want: "1684"},
+ {iso: "RU", want: "7"},
+ // Secondaries that share a dial code still resolve to that dial code.
+ {iso: "CA", want: "1"},
+ {iso: "KZ", want: "7"},
+ {iso: "ZZ", want: ""},
+ {iso: "", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.iso, func(t *testing.T) {
+ if got := DialCodeForISO(tt.iso); got != tt.want {
+ t.Errorf("DialCodeForISO(%q) = %q, want %q", tt.iso, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/image/image.go b/internal/image/image.go
index ff2045ca9..fea32927d 100644
--- a/internal/image/image.go
+++ b/internal/image/image.go
@@ -11,6 +11,9 @@ import (
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
+
+ // Registers the WebP decoder with image.Decode, which imaging.Decode uses.
+ _ "golang.org/x/image/webp"
)
const (
@@ -25,7 +28,7 @@ const (
)
var (
- Exts = []string{"gif", "png", "jpg", "jpeg"}
+ Exts = []string{"gif", "png", "jpg", "jpeg", "webp"}
DefThumbSize = 150
ThumbPrefix = "thumb_"
)
@@ -44,7 +47,7 @@ func IsImageByContent(r io.ReadSeeker) bool {
return false
}
switch mtype.String() {
- case "image/png", "image/jpeg", "image/gif":
+ case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
}
return false
diff --git a/internal/inbox/channel/email/email.go b/internal/inbox/channel/email/email.go
index 913e59bf0..e5f5a13f5 100644
--- a/internal/inbox/channel/email/email.go
+++ b/internal/inbox/channel/email/email.go
@@ -41,12 +41,16 @@ type Email struct {
userStore inbox.UserStore
wg sync.WaitGroup
tokenRefreshCallback TokenRefreshCallback
+ authStatusCallback AuthStatusCallback
}
// TokenRefreshCallback is called when OAuth tokens are refreshed.
// It receives the inbox ID and the updated config with new tokens.
type TokenRefreshCallback func(inboxID int, updatedConfig models.Config) error
+// AuthStatusCallback reports the provider's latest verdict on the inbox credentials; ok=true clears a previously flagged failure.
+type AuthStatusCallback func(inboxID int, ok bool)
+
// Opts holds the options required for the email inbox.
type Opts struct {
ID int
@@ -55,6 +59,7 @@ type Opts struct {
Config models.Config
Lo *logf.Logger
TokenRefreshCallback TokenRefreshCallback // Optional callback for token refresh
+ AuthStatusCallback AuthStatusCallback
}
// New returns a new instance of the email inbox.
@@ -87,6 +92,7 @@ func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*Email
authType: opts.Config.AuthType,
enablePlusAddressing: opts.Config.EnablePlusAddressing,
tokenRefreshCallback: opts.TokenRefreshCallback,
+ authStatusCallback: opts.AuthStatusCallback,
}
return e, nil
}
@@ -183,6 +189,7 @@ func (e *Email) refreshOAuthIfNeeded() (*models.OAuthConfig, bool, error) {
if err != nil {
e.oauthMu.Unlock()
e.lo.Error("Failed to refresh OAuth token", "inbox_id", e.Identifier(), "error", err)
+ e.flagAuthError()
return nil, false, fmt.Errorf("OAuth token expired and refresh failed for inbox %d: %w", e.Identifier(), err)
}
@@ -200,9 +207,22 @@ func (e *Email) refreshOAuthIfNeeded() (*models.OAuthConfig, bool, error) {
}
e.lo.Info("Successfully refreshed OAuth token", "inbox_id", e.Identifier())
+ e.clearAuthError()
return oauthCopy, true, nil
}
+func (e *Email) flagAuthError() {
+ if e.authStatusCallback != nil {
+ e.authStatusCallback(e.Identifier(), false)
+ }
+}
+
+func (e *Email) clearAuthError() {
+ if e.authStatusCallback != nil {
+ e.authStatusCallback(e.Identifier(), true)
+ }
+}
+
// closeSMTPPool closes the smtp pool.
func (e *Email) closeSMTPPool() error {
e.smtpPoolsMu.Lock()
diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go
index 4d681a167..cdbfa2d7e 100644
--- a/internal/inbox/channel/email/imap.go
+++ b/internal/inbox/channel/email/imap.go
@@ -106,13 +106,16 @@ func (e *Email) processMailbox(ctx context.Context, scanInboxSince time.Duration
token: oauthConfig.AccessToken,
}
if err := client.Authenticate(saslClient); err != nil {
+ e.flagAuthError()
return fmt.Errorf("error authenticating with OAuth to IMAP server: %w", err)
}
} else {
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
+ e.flagAuthError()
return fmt.Errorf("error logging in to the IMAP server: %w", err)
}
}
+ e.clearAuthError()
if _, err := client.Select(cfg.Mailbox, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return fmt.Errorf("error selecting mailbox: %w", err)
diff --git a/internal/inbox/channel/whatsapp/whatsapp.go b/internal/inbox/channel/whatsapp/whatsapp.go
new file mode 100644
index 000000000..dc4c491f1
--- /dev/null
+++ b/internal/inbox/channel/whatsapp/whatsapp.go
@@ -0,0 +1,324 @@
+// Package whatsapp implements a WhatsApp Cloud API inbox.
+package whatsapp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/abhinavxd/libredesk/internal/attachment"
+ "github.com/abhinavxd/libredesk/internal/conversation/models"
+ "github.com/abhinavxd/libredesk/internal/inbox"
+ "github.com/abhinavxd/libredesk/internal/stringutil"
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ "github.com/zerodha/logf"
+)
+
+const ChannelWhatsApp = "whatsapp"
+
+const MetaCallTimeout = 30 * time.Second
+
+// Meta's published per-media-type upload size caps.
+const (
+ maxImageBytes = 5 * 1024 * 1024
+ maxVideoBytes = 16 * 1024 * 1024
+ maxAudioBytes = 16 * 1024 * 1024
+ maxDocumentBytes = 100 * 1024 * 1024
+)
+
+var supportedMediaMIMETypes = map[string]struct{}{
+ "audio/aac": {},
+ "audio/mp4": {},
+ "audio/mpeg": {},
+ "audio/amr": {},
+ "audio/ogg": {},
+ "audio/opus": {},
+ "application/vnd.ms-powerpoint": {},
+ "application/msword": {},
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": {},
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": {},
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {},
+ "application/pdf": {},
+ "text/plain": {},
+ "application/vnd.ms-excel": {},
+ "image/jpeg": {},
+ "image/png": {},
+ "video/mp4": {},
+ "video/3gpp": {},
+ "video/3gp": {},
+}
+
+// Config is the per-inbox WhatsApp configuration from the inbox config JSONB, with tokens already decrypted.
+type Config struct {
+ PhoneNumberID string `json:"phone_number_id"`
+ WABAID string `json:"waba_id"`
+ AccessToken string `json:"access_token"`
+ AppSecret string `json:"app_secret"`
+ WebhookVerifyToken string `json:"webhook_verify_token"`
+ APIVersion string `json:"api_version"`
+
+ CSATTemplateLanguage string `json:"csat_template_language"`
+ CSATTemplateBody string `json:"csat_template_body"`
+ CSATTemplateButtonText string `json:"csat_template_button_text"`
+}
+
+func (c Config) Account() whatsapp.Account {
+ return whatsapp.Account{
+ PhoneNumberID: c.PhoneNumberID,
+ WABAID: c.WABAID,
+ AccessToken: c.AccessToken,
+ AppSecret: c.AppSecret,
+ APIVersion: c.APIVersion,
+ }
+}
+
+// SendMeta is the per-message metadata threaded through OutboundMessage.Meta; a set TemplateName means a template send.
+type SendMeta struct {
+ ToPhone string `json:"to_phone"`
+ ReplyToWAMessageID string `json:"reply_to_wa_message_id,omitempty"`
+ TemplateName string `json:"template_name,omitempty"`
+ TemplateLanguage string `json:"template_language,omitempty"`
+ TemplateParams map[string]string `json:"template_params,omitempty"`
+ TemplateHeaderType string `json:"template_header_type,omitempty"`
+ TemplateHeaderContent string `json:"template_header_content,omitempty"`
+ TemplateBodyContent string `json:"template_body_content,omitempty"`
+ TemplateButtons []whatsapp.TemplateButton `json:"template_buttons,omitempty"`
+}
+
+// SourceIDUpdater persists the Meta message ID for status correlation.
+type SourceIDUpdater interface {
+ UpdateMessageSourceID(messageUUID, sourceID string) error
+}
+
+type WhatsApp struct {
+ id int
+ name string
+ config Config
+ client *whatsapp.Client
+ lo *logf.Logger
+ messageStore inbox.MessageStore
+ sourceUpdater SourceIDUpdater
+}
+
+type Opts struct {
+ ID int
+ Name string
+ Config Config
+ Client *whatsapp.Client
+ Lo *logf.Logger
+ SourceUpdater SourceIDUpdater
+}
+
+func New(store inbox.MessageStore, opts Opts) (*WhatsApp, error) {
+ if opts.Client == nil {
+ return nil, fmt.Errorf("whatsapp client is required")
+ }
+ if opts.Config.PhoneNumberID == "" || opts.Config.AccessToken == "" {
+ return nil, fmt.Errorf("phone_number_id and access_token are required")
+ }
+ if opts.Lo == nil {
+ return nil, fmt.Errorf("logger is required")
+ }
+ return &WhatsApp{
+ id: opts.ID,
+ name: opts.Name,
+ config: opts.Config,
+ client: opts.Client,
+ lo: opts.Lo,
+ messageStore: store,
+ sourceUpdater: opts.SourceUpdater,
+ }, nil
+}
+
+func (w *WhatsApp) Identifier() int { return w.id }
+func (w *WhatsApp) Config() Config { return w.config }
+func (w *WhatsApp) Channel() string { return ChannelWhatsApp }
+func (w *WhatsApp) Name() string { return w.name }
+func (w *WhatsApp) FromAddress() string { return "" }
+func (w *WhatsApp) ReplyToAddress() string { return "" }
+func (w *WhatsApp) FromNameTemplate() string { return "" }
+func (w *WhatsApp) Close() error { return nil }
+
+// Receive is a no-op; inbound messages arrive via the webhook handler.
+func (w *WhatsApp) Receive(ctx context.Context) error { return nil }
+
+func (w *WhatsApp) Send(message models.OutboundMessage) error {
+ meta, err := parseSendMeta(message.Meta)
+ if err != nil {
+ return fmt.Errorf("parsing whatsapp send meta: %w", err)
+ }
+ if meta.ToPhone == "" {
+ return fmt.Errorf("missing recipient phone number on outbound message")
+ }
+
+ // An attachment costs two calls: the media upload and the send.
+ timeout := MetaCallTimeout
+ if len(message.Attachments) > 0 {
+ timeout = 2 * MetaCallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ acc := w.config.Account()
+ var sourceID string
+
+ switch {
+ case meta.TemplateName != "":
+ components := whatsapp.BuildSendComponents(whatsapp.TemplateSendParts{
+ HeaderType: meta.TemplateHeaderType,
+ HeaderContent: meta.TemplateHeaderContent,
+ BodyContent: meta.TemplateBodyContent,
+ Buttons: meta.TemplateButtons,
+ Params: meta.TemplateParams,
+ })
+ sourceID, err = w.client.SendTemplate(ctx, acc, meta.ToPhone, meta.TemplateName, meta.TemplateLanguage, components)
+
+ case len(message.Attachments) > 0:
+ sourceID, err = w.sendAttachment(ctx, acc, meta, message)
+
+ case strings.TrimSpace(textBody(message)) != "":
+ sourceID, err = w.client.SendText(ctx, acc, meta.ToPhone, textBody(message), meta.ReplyToWAMessageID)
+
+ default:
+ return fmt.Errorf("outbound message has no content")
+ }
+
+ if sourceID != "" && w.sourceUpdater != nil {
+ if upErr := w.sourceUpdater.UpdateMessageSourceID(message.UUID, sourceID); upErr != nil {
+ w.lo.Error("failed to persist whatsapp source id", "message_uuid", message.UUID, "source_id", sourceID, "error", upErr)
+ }
+ }
+ return err
+}
+
+// sendAttachment uploads and sends one attachment; Meta accepts only one media per message.
+func (w *WhatsApp) sendAttachment(ctx context.Context, acc whatsapp.Account, meta SendMeta, message models.OutboundMessage) (string, error) {
+ if len(message.Attachments) > 1 {
+ return "", fmt.Errorf("whatsapp accepts one attachment per message, got %d", len(message.Attachments))
+ }
+ if bad := rejectedAttachments(message.Attachments); len(bad) > 0 {
+ return "", fmt.Errorf("WhatsApp can't send these files: %s", strings.Join(bad, "; "))
+ }
+
+ att := message.Attachments[0]
+ mediaID, err := w.client.UploadMedia(ctx, acc, att.Content, att.ContentType, att.Name)
+ if err != nil {
+ return "", fmt.Errorf("uploading attachment to meta: %w", err)
+ }
+ return w.client.SendMedia(ctx, acc, meta.ToPhone, mediaTypeForAttachment(att), mediaID, strings.TrimSpace(textBody(message)), att.Name, meta.ReplyToWAMessageID)
+}
+
+// SupportsCaption reports whether media of this content type can carry a caption; audio can't.
+func SupportsCaption(contentType string) bool {
+ return mediaTypeForAttachment(attachment.Attachment{ContentType: contentType}) != "audio"
+}
+
+// RejectMediaReason returns why WhatsApp won't accept the file, or an empty string when it will.
+func RejectMediaReason(name, contentType string, size int) string {
+ reasons := rejectedAttachments([]attachment.Attachment{{Name: name, ContentType: contentType, Size: size}})
+ if len(reasons) == 0 {
+ return ""
+ }
+ return reasons[0]
+}
+
+func parseSendMeta(raw json.RawMessage) (SendMeta, error) {
+ var meta SendMeta
+ if len(raw) == 0 {
+ return meta, nil
+ }
+ // SendMeta lives under a "whatsapp" key in message.meta to avoid colliding with email's to/cc keys.
+ var envelope struct {
+ WhatsApp json.RawMessage `json:"whatsapp"`
+ }
+ if err := json.Unmarshal(raw, &envelope); err == nil && len(envelope.WhatsApp) > 0 {
+ if err := json.Unmarshal(envelope.WhatsApp, &meta); err != nil {
+ return meta, fmt.Errorf("decoding whatsapp meta envelope: %w", err)
+ }
+ return meta, nil
+ }
+ if err := json.Unmarshal(raw, &meta); err != nil {
+ return meta, err
+ }
+ return meta, nil
+}
+
+// textBody returns the plain-text body; raw HTML must never reach WhatsApp verbatim.
+func textBody(m models.OutboundMessage) string {
+ if m.TextContent != "" {
+ return m.TextContent
+ }
+ if m.ContentType == models.ContentTypeHTML {
+ return stringutil.HTML2Text(m.Content)
+ }
+ return m.Content
+}
+
+func rejectedAttachments(atts []attachment.Attachment) []string {
+ var reasons []string
+ for _, att := range atts {
+ mime := normalizeMIME(att.ContentType)
+ if mime == "image/webp" {
+ reasons = append(reasons, fmt.Sprintf("%s (WebP images aren't supported; convert to JPEG or PNG)", att.Name))
+ continue
+ }
+ if _, ok := supportedMediaMIMETypes[mime]; !ok {
+ reasons = append(reasons, fmt.Sprintf("%s (unsupported type %s)", att.Name, att.ContentType))
+ continue
+ }
+ mediaType := mediaTypeForAttachment(att)
+ if max := maxMediaBytes(mediaType); att.Size > max {
+ reasons = append(reasons, fmt.Sprintf("%s (%s exceeds the %s %s limit)", att.Name, humanBytes(att.Size), humanBytes(max), mediaType))
+ }
+ }
+ return reasons
+}
+
+func maxMediaBytes(mediaType string) int {
+ switch mediaType {
+ case "image":
+ return effectiveLimit(maxImageBytes)
+ case "video":
+ return effectiveLimit(maxVideoBytes)
+ case "audio":
+ return effectiveLimit(maxAudioBytes)
+ }
+ return effectiveLimit(maxDocumentBytes)
+}
+
+func humanBytes(n int) string {
+ switch {
+ case n >= 1024*1024:
+ return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
+ case n >= 1024:
+ return fmt.Sprintf("%.0f KB", float64(n)/1024)
+ }
+ return fmt.Sprintf("%d B", n)
+}
+
+func normalizeMIME(contentType string) string {
+ mime := strings.ToLower(strings.TrimSpace(contentType))
+ if i := strings.Index(mime, ";"); i >= 0 {
+ mime = strings.TrimSpace(mime[:i])
+ }
+ return mime
+}
+
+func mediaTypeForAttachment(att attachment.Attachment) string {
+ switch normalizeMIME(att.ContentType) {
+ case "image/jpeg", "image/png":
+ return "image"
+ case "video/mp4", "video/3gpp", "video/3gp":
+ return "video"
+ case "audio/aac", "audio/mp4", "audio/mpeg", "audio/amr", "audio/ogg", "audio/opus":
+ return "audio"
+ }
+ return "document"
+}
+
+// effectiveLimit keeps 2% headroom below Meta's hard caps to absorb size-measurement skew at the boundary.
+func effectiveLimit(n int) int {
+ return n * 98 / 100
+}
diff --git a/internal/inbox/channel/whatsapp/whatsapp_test.go b/internal/inbox/channel/whatsapp/whatsapp_test.go
new file mode 100644
index 000000000..bf615a667
--- /dev/null
+++ b/internal/inbox/channel/whatsapp/whatsapp_test.go
@@ -0,0 +1,548 @@
+package whatsapp
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/abhinavxd/libredesk/internal/attachment"
+ "github.com/abhinavxd/libredesk/internal/conversation/models"
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ "github.com/zerodha/logf"
+)
+
+func TestConfigAccount(t *testing.T) {
+ cfg := Config{
+ PhoneNumberID: "PN1",
+ WABAID: "WABA1",
+ AccessToken: "TOKEN",
+ AppSecret: "SECRET",
+ APIVersion: "v25.0",
+ }
+ acc := cfg.Account()
+ if acc.PhoneNumberID != "PN1" || acc.WABAID != "WABA1" || acc.AccessToken != "TOKEN" || acc.AppSecret != "SECRET" || acc.APIVersion != "v25.0" {
+ t.Fatalf("unexpected account: %+v", acc)
+ }
+ // The CSAT fields are libredesk-side and must not leak into Meta calls.
+ if acc.Version() != "v25.0" {
+ t.Fatalf("unexpected version %q", acc.Version())
+ }
+}
+
+func TestNew(t *testing.T) {
+ client := whatsapp.New(testLogger())
+ tests := []struct {
+ name string
+ opts Opts
+ wantErr string
+ }{
+ {
+ name: "no client",
+ opts: Opts{ID: 1, Config: Config{PhoneNumberID: "PN1", AccessToken: "T"}, Lo: testLogger()},
+ wantErr: "client is required",
+ },
+ {
+ name: "no phone number id",
+ opts: Opts{ID: 1, Config: Config{AccessToken: "T"}, Client: client, Lo: testLogger()},
+ wantErr: "phone_number_id and access_token are required",
+ },
+ {
+ name: "no access token",
+ opts: Opts{ID: 1, Config: Config{PhoneNumberID: "PN1"}, Client: client, Lo: testLogger()},
+ wantErr: "phone_number_id and access_token are required",
+ },
+ {
+ name: "no logger",
+ opts: Opts{ID: 1, Config: Config{PhoneNumberID: "PN1", AccessToken: "T"}, Client: client},
+ wantErr: "logger is required",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := New(nil, tc.opts); err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("expected %q, got %v", tc.wantErr, err)
+ }
+ })
+ }
+}
+
+func TestInboxAccessors(t *testing.T) {
+ inb := testInbox(t, nil, nil)
+ if inb.Identifier() != 7 {
+ t.Fatalf("unexpected id %d", inb.Identifier())
+ }
+ if inb.Name() != "WA Inbox" {
+ t.Fatalf("unexpected name %q", inb.Name())
+ }
+ if inb.Channel() != ChannelWhatsApp {
+ t.Fatalf("unexpected channel %q", inb.Channel())
+ }
+ if inb.Config().PhoneNumberID != "PN1" {
+ t.Fatalf("unexpected config %+v", inb.Config())
+ }
+ // The email template machinery reads these, and WhatsApp has no address to give it.
+ if inb.FromAddress() != "" || inb.ReplyToAddress() != "" || inb.FromNameTemplate() != "" {
+ t.Fatal("expected empty email fields")
+ }
+ if err := inb.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+ // Inbound arrives over the webhook, so Receive does nothing.
+ if err := inb.Receive(context.Background()); err != nil {
+ t.Fatalf("receive: %v", err)
+ }
+}
+
+func TestSendText(t *testing.T) {
+ var body map[string]any
+ updater := &fakeSourceUpdater{}
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ decodeBody(t, r, &body)
+ writeSendResponse(w, "wamid.OUT1")
+ }, updater)
+
+ err := inb.Send(models.OutboundMessage{
+ UUID: "msg-uuid",
+ Content: "hello there
",
+ ContentType: models.ContentTypeHTML,
+ Meta: json.RawMessage(`{"whatsapp":{"to_phone":"919876543210"}}`),
+ })
+ if err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ if body["type"] != "text" {
+ t.Fatalf("expected a text send, got %v", body)
+ }
+ if got := body["text"].(map[string]any)["body"].(string); strings.Contains(got, "<") {
+ t.Fatalf("HTML must be flattened before it reaches WhatsApp, got %q", got)
+ }
+ // The Meta message id is what later status webhooks are matched on.
+ if updater.uuid != "msg-uuid" || updater.sourceID != "wamid.OUT1" {
+ t.Fatalf("unexpected source id update: %+v", updater)
+ }
+}
+
+func TestSendTemplate(t *testing.T) {
+ var body map[string]any
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ decodeBody(t, r, &body)
+ writeSendResponse(w, "wamid.OUT2")
+ }, &fakeSourceUpdater{})
+
+ meta := SendMeta{
+ ToPhone: "919876543210",
+ TemplateName: "order_update",
+ TemplateLanguage: "en_US",
+ TemplateBodyContent: "Hi {{name}}",
+ TemplateParams: map[string]string{"body:name": "Ravi"},
+ }
+ raw, _ := json.Marshal(map[string]any{"whatsapp": meta})
+ if err := inb.Send(models.OutboundMessage{UUID: "u", Meta: raw}); err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ tmpl := body["template"].(map[string]any)
+ if tmpl["name"] != "order_update" {
+ t.Fatalf("unexpected template: %v", tmpl)
+ }
+ if len(tmpl["components"].([]any)) != 1 {
+ t.Fatalf("expected the body component, got %v", tmpl["components"])
+ }
+}
+
+// A template send wins over any free-form content on the same message.
+func TestSendTemplateTakesPrecedenceOverContent(t *testing.T) {
+ var body map[string]any
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ decodeBody(t, r, &body)
+ writeSendResponse(w, "wamid.OUT3")
+ }, nil)
+ raw, _ := json.Marshal(map[string]any{"whatsapp": SendMeta{ToPhone: "91", TemplateName: "t", TemplateLanguage: "en_US"}})
+ if err := inb.Send(models.OutboundMessage{UUID: "u", TextContent: "free form", Meta: raw}); err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ if body["type"] != "template" {
+ t.Fatalf("expected a template send, got %v", body["type"])
+ }
+}
+
+func TestSendAttachment(t *testing.T) {
+ var (
+ uploaded string
+ sent map[string]any
+ )
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/media") {
+ r.ParseMultipartForm(1 << 20)
+ uploaded = r.MultipartForm.File["file"][0].Filename
+ writeJSONBody(w, map[string]string{"id": "MEDIAUP1"})
+ return
+ }
+ decodeBody(t, r, &sent)
+ writeSendResponse(w, "wamid.OUT4")
+ }, nil)
+
+ err := inb.Send(models.OutboundMessage{
+ UUID: "u",
+ TextContent: "see attached",
+ Meta: json.RawMessage(`{"whatsapp":{"to_phone":"919876543210"}}`),
+ Attachments: attachment.Attachments{{Name: "invoice.pdf", ContentType: "application/pdf", Content: []byte("pdfbytes"), Size: 8}},
+ })
+ if err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ if uploaded != "invoice.pdf" {
+ t.Fatalf("unexpected upload %q", uploaded)
+ }
+ doc := sent["document"].(map[string]any)
+ if sent["type"] != "document" || doc["id"] != "MEDIAUP1" || doc["caption"] != "see attached" || doc["filename"] != "invoice.pdf" {
+ t.Fatalf("unexpected send payload: %v", sent)
+ }
+}
+
+func TestSendAttachmentFailures(t *testing.T) {
+ tests := []struct {
+ name string
+ message models.OutboundMessage
+ handler http.HandlerFunc
+ wantErrPart string
+ }{
+ {
+ name: "two attachments",
+ message: models.OutboundMessage{
+ UUID: "u",
+ Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`),
+ Attachments: attachment.Attachments{
+ {Name: "a.pdf", ContentType: "application/pdf", Size: 1},
+ {Name: "b.pdf", ContentType: "application/pdf", Size: 1},
+ },
+ },
+ wantErrPart: "one attachment per message",
+ },
+ {
+ name: "unsupported type",
+ message: models.OutboundMessage{
+ UUID: "u",
+ Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`),
+ Attachments: attachment.Attachments{{Name: "logs.zip", ContentType: "application/zip", Size: 1}},
+ },
+ wantErrPart: "can't send these files",
+ },
+ {
+ name: "upload rejected by meta",
+ message: models.OutboundMessage{
+ UUID: "u",
+ Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`),
+ Attachments: attachment.Attachments{{Name: "a.pdf", ContentType: "application/pdf", Content: []byte("x"), Size: 1}},
+ },
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"too big","code":100}}`))
+ },
+ wantErrPart: "uploading attachment to meta",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ inb := testInbox(t, tc.handler, nil)
+ err := inb.Send(tc.message)
+ if err == nil || !strings.Contains(err.Error(), tc.wantErrPart) {
+ t.Fatalf("expected %q, got %v", tc.wantErrPart, err)
+ }
+ })
+ }
+}
+
+func TestSendRejectsUnusableMessages(t *testing.T) {
+ tests := []struct {
+ name string
+ message models.OutboundMessage
+ wantErrPart string
+ }{
+ {
+ name: "no recipient",
+ message: models.OutboundMessage{UUID: "u", TextContent: "hi"},
+ wantErrPart: "missing recipient phone number",
+ },
+ {
+ name: "malformed meta",
+ message: models.OutboundMessage{UUID: "u", Meta: json.RawMessage(`{"whatsapp":"not an object"}`)},
+ wantErrPart: "parsing whatsapp send meta",
+ },
+ {
+ name: "nothing to send",
+ message: models.OutboundMessage{UUID: "u", Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`)},
+ wantErrPart: "no content",
+ },
+ {
+ name: "whitespace only",
+ message: models.OutboundMessage{UUID: "u", TextContent: " ", Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`)},
+ wantErrPart: "no content",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ inb := testInbox(t, nil, nil)
+ err := inb.Send(tc.message)
+ if err == nil || !strings.Contains(err.Error(), tc.wantErrPart) {
+ t.Fatalf("expected %q, got %v", tc.wantErrPart, err)
+ }
+ })
+ }
+}
+
+// Meta accepted the message, so the id has to be stored even though the update itself failed.
+func TestSendKeepsGoingWhenSourceIDUpdateFails(t *testing.T) {
+ updater := &fakeSourceUpdater{err: errors.New("db down")}
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSendResponse(w, "wamid.OUT5")
+ }, updater)
+ err := inb.Send(models.OutboundMessage{UUID: "u", TextContent: "hi", Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`)})
+ if err != nil {
+ t.Fatalf("send: %v", err)
+ }
+ if updater.calls != 1 {
+ t.Fatalf("expected one update attempt, got %d", updater.calls)
+ }
+}
+
+func TestSendWithoutSourceUpdater(t *testing.T) {
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSendResponse(w, "wamid.OUT6")
+ }, nil)
+ if err := inb.Send(models.OutboundMessage{UUID: "u", TextContent: "hi", Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`)}); err != nil {
+ t.Fatalf("send: %v", err)
+ }
+}
+
+func TestSendSurfacesMetaError(t *testing.T) {
+ inb := testInbox(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"outside window","code":131047,"error_user_msg":"more than 24 hours"}}`))
+ }, &fakeSourceUpdater{})
+ err := inb.Send(models.OutboundMessage{UUID: "u", TextContent: "hi", Meta: json.RawMessage(`{"whatsapp":{"to_phone":"91"}}`)})
+ if err == nil || !strings.Contains(err.Error(), "more than 24 hours") {
+ t.Fatalf("expected the Meta user message, got %v", err)
+ }
+}
+
+func TestHumanBytes(t *testing.T) {
+ tests := map[int]string{
+ 512: "512 B",
+ 2048: "2 KB",
+ 5 * 1024 * 1024: "5.0 MB",
+ 16 * 1024 * 1024: "16.0 MB",
+ }
+ for size, want := range tests {
+ if got := humanBytes(size); got != want {
+ t.Errorf("%d: expected %q, got %q", size, want, got)
+ }
+ }
+}
+
+func TestMaxMediaBytes(t *testing.T) {
+ if maxMediaBytes("audio") != effectiveLimit(maxAudioBytes) {
+ t.Fatal("audio must use the audio cap")
+ }
+ if maxMediaBytes("sticker") != effectiveLimit(maxDocumentBytes) {
+ t.Fatal("an unknown media type must fall back to the document cap")
+ }
+}
+
+func TestRejectMediaReason(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ contentType string
+ size int
+ wantMatch string
+ }{
+ {"pdf under cap", "invoice.pdf", "application/pdf", 1024, ""},
+ {"jpeg under cap", "photo.jpg", "image/jpeg", 1024, ""},
+ {"jpeg with charset parameter", "photo.jpg", "image/jpeg; charset=binary", 1024, ""},
+ {"uppercase mime", "photo.jpg", "IMAGE/JPEG", 1024, ""},
+ {"webp", "sticker.webp", "image/webp", 1024, "WebP"},
+ {"zip", "logs.zip", "application/zip", 1024, "unsupported type"},
+ {"oversized image", "big.jpg", "image/jpeg", 6 * 1024 * 1024, "image limit"},
+ {"oversized video", "big.mp4", "video/mp4", 20 * 1024 * 1024, "video limit"},
+ {"oversized document", "big.pdf", "application/pdf", 101 * 1024 * 1024, "document limit"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := RejectMediaReason(tc.file, tc.contentType, tc.size)
+ if tc.wantMatch == "" {
+ if got != "" {
+ t.Fatalf("expected the file to be accepted, got %q", got)
+ }
+ return
+ }
+ if !strings.Contains(got, tc.wantMatch) {
+ t.Fatalf("expected reason to mention %q, got %q", tc.wantMatch, got)
+ }
+ })
+ }
+}
+
+// An image just under Meta's cap must pass while the 2% headroom keeps one at the cap out.
+func TestRejectMediaReasonImageBoundary(t *testing.T) {
+ if got := RejectMediaReason("photo.jpg", "image/jpeg", effectiveLimit(maxImageBytes)); got != "" {
+ t.Fatalf("expected the file at the effective limit to be accepted, got %q", got)
+ }
+ if got := RejectMediaReason("photo.jpg", "image/jpeg", maxImageBytes); got == "" {
+ t.Fatal("expected a file at Meta's hard cap to be rejected by the headroom")
+ }
+}
+
+func TestMediaTypeForAttachment(t *testing.T) {
+ tests := map[string]string{
+ "image/jpeg": "image",
+ "image/png": "image",
+ "video/mp4": "video",
+ "video/3gpp": "video",
+ "audio/ogg": "audio",
+ "audio/mpeg": "audio",
+ "application/pdf": "document",
+ "text/plain": "document",
+ "application/zip": "document",
+ "IMAGE/PNG ": "image",
+ "image/png; x=y": "image",
+ "application/json": "document",
+ }
+ for contentType, want := range tests {
+ if got := mediaTypeForAttachment(attachment.Attachment{ContentType: contentType}); got != want {
+ t.Errorf("%s: expected %s, got %s", contentType, want, got)
+ }
+ }
+}
+
+func TestSupportsCaption(t *testing.T) {
+ if SupportsCaption("audio/ogg") {
+ t.Fatal("audio must not advertise caption support")
+ }
+ for _, contentType := range []string{"image/jpeg", "video/mp4", "application/pdf"} {
+ if !SupportsCaption(contentType) {
+ t.Errorf("%s should support a caption", contentType)
+ }
+ }
+}
+
+func TestParseSendMeta(t *testing.T) {
+ t.Run("envelope key wins", func(t *testing.T) {
+ meta, err := parseSendMeta(json.RawMessage(`{"to":["a@b.c"],"whatsapp":{"to_phone":"919876543210","template_name":"order_update"}}`))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if meta.ToPhone != "919876543210" || meta.TemplateName != "order_update" {
+ t.Fatalf("unexpected meta: %+v", meta)
+ }
+ })
+
+ t.Run("flat payload", func(t *testing.T) {
+ meta, err := parseSendMeta(json.RawMessage(`{"to_phone":"919876543210"}`))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if meta.ToPhone != "919876543210" {
+ t.Fatalf("unexpected meta: %+v", meta)
+ }
+ })
+
+ t.Run("empty meta", func(t *testing.T) {
+ meta, err := parseSendMeta(nil)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if meta.ToPhone != "" {
+ t.Fatalf("expected a zero meta, got %+v", meta)
+ }
+ })
+
+ t.Run("malformed meta", func(t *testing.T) {
+ if _, err := parseSendMeta(json.RawMessage(`not json`)); err == nil {
+ t.Fatal("expected an error for malformed meta")
+ }
+ })
+}
+
+func TestTextBody(t *testing.T) {
+ if got := textBody(models.OutboundMessage{TextContent: "plain", Content: "html
"}); got != "plain" {
+ t.Fatalf("expected the stored text content, got %q", got)
+ }
+ got := textBody(models.OutboundMessage{ContentType: models.ContentTypeHTML, Content: "hello there
"})
+ if strings.Contains(got, "<") {
+ t.Fatalf("expected HTML to be flattened, got %q", got)
+ }
+ if !strings.Contains(got, "hello") {
+ t.Fatalf("expected the text to survive, got %q", got)
+ }
+}
+
+func testInbox(t *testing.T, handler http.HandlerFunc, updater SourceIDUpdater) *WhatsApp {
+ t.Helper()
+ if handler == nil {
+ handler = func(w http.ResponseWriter, r *http.Request) { writeSendResponse(w, "wamid.DEFAULT") }
+ }
+ srv := httptest.NewServer(handler)
+ t.Cleanup(srv.Close)
+
+ client := whatsapp.New(testLogger())
+ client.SetBaseURL(srv.URL)
+
+ inb, err := New(nil, Opts{
+ ID: 7,
+ Name: "WA Inbox",
+ Config: Config{PhoneNumberID: "PN1", WABAID: "WABA1", AccessToken: "TOKEN", APIVersion: "v25.0"},
+ Client: client,
+ Lo: testLogger(),
+ SourceUpdater: updater,
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return inb
+}
+
+func testLogger() *logf.Logger {
+ l := logf.New(logf.Opts{Level: logf.FatalLevel})
+ return &l
+}
+
+func writeJSONBody(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+}
+
+func writeSendResponse(w http.ResponseWriter, id string) {
+ writeJSONBody(w, map[string]any{
+ "messaging_product": "whatsapp",
+ "messages": []map[string]string{{"id": id}},
+ })
+}
+
+func decodeBody(t *testing.T, r *http.Request, out any) {
+ t.Helper()
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ t.Fatalf("unmarshal %q: %v", raw, err)
+ }
+}
+
+type fakeSourceUpdater struct {
+ uuid string
+ sourceID string
+ calls int
+ err error
+}
+
+func (f *fakeSourceUpdater) UpdateMessageSourceID(messageUUID, sourceID string) error {
+ f.calls++
+ f.uuid, f.sourceID = messageUUID, sourceID
+ return f.err
+}
diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go
index fcf82c50e..bb37b44ed 100644
--- a/internal/inbox/inbox.go
+++ b/internal/inbox/inbox.go
@@ -28,6 +28,7 @@ import (
const (
ChannelEmail = "email"
ChannelLiveChat = "livechat"
+ ChannelWhatsApp = "whatsapp"
)
var (
@@ -256,7 +257,7 @@ func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) {
}
var createdInbox imodels.Inbox
- if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.Enabled, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
+ if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.Enabled, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.ReopenWindowHours, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
m.lo.Error("error creating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -421,6 +422,12 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
}
inbox.Secret = null.StringFrom(encryptedSecret)
}
+ case ChannelWhatsApp:
+ merged, err := m.MergeWhatsAppSecrets(current.Config, inbox.Config)
+ if err != nil {
+ return imodels.Inbox{}, err
+ }
+ inbox.Config = merged
}
// Encrypt sensitive fields before updating
@@ -432,7 +439,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
// Update the inbox in the DB.
var updatedInbox imodels.Inbox
- if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
+ if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.ReopenWindowHours, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
m.lo.Error("error updating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -451,6 +458,36 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
return updatedInbox, nil
}
+// MergeWhatsAppSecrets restores masked or empty secret fields in an update config from the currently stored config.
+func (m *Manager) MergeWhatsAppSecrets(current, update json.RawMessage) (json.RawMessage, error) {
+ var currentCfg, updateCfg map[string]any
+ if err := json.Unmarshal(current, ¤tCfg); err != nil {
+ m.lo.Error("error unmarshalling current whatsapp config", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ if len(update) == 0 {
+ return nil, envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.empty", "name", "{globals.terms.config}"), nil)
+ }
+ if err := json.Unmarshal(update, &updateCfg); err != nil {
+ m.lo.Error("error unmarshalling whatsapp update config", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ for _, fieldName := range []string{"access_token", "app_secret", "webhook_verify_token"} {
+ val, _ := updateCfg[fieldName].(string)
+ if val == "" || strings.Contains(val, stringutil.PasswordDummy) {
+ if existing, ok := currentCfg[fieldName].(string); ok {
+ updateCfg[fieldName] = existing
+ }
+ }
+ }
+ merged, err := json.Marshal(updateCfg)
+ if err != nil {
+ m.lo.Error("error marshalling whatsapp merged config", "error", err)
+ return nil, err
+ }
+ return merged, nil
+}
+
// Toggle toggles the status of an inbox in the DB.
func (m *Manager) Toggle(id int) (imodels.Inbox, error) {
var updatedInbox imodels.Inbox
@@ -653,6 +690,16 @@ func (m *Manager) encryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
}
+ for _, fieldName := range []string{"access_token", "app_secret", "webhook_verify_token"} {
+ if value, ok := cfg[fieldName].(string); ok && value != "" && !crypto.IsEncrypted(value) {
+ encrypted, err := crypto.Encrypt(value, m.encryptionKey)
+ if err != nil {
+ return nil, fmt.Errorf("encrypting whatsapp %s: %w", fieldName, err)
+ }
+ cfg[fieldName] = encrypted
+ }
+ }
+
encrypted, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshalling encrypted config: %w", err)
@@ -719,6 +766,18 @@ func (m *Manager) decryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
}
+ for _, fieldName := range []string{"access_token", "app_secret", "webhook_verify_token"} {
+ if value, ok := cfg[fieldName].(string); ok && crypto.IsEncrypted(value) {
+ decrypted, err := crypto.Decrypt(value, m.encryptionKey)
+ if err != nil {
+ m.lo.Error("error decrypting whatsapp credential, clearing field", "field", fieldName, "error", err)
+ cfg[fieldName] = ""
+ continue
+ }
+ cfg[fieldName] = decrypted
+ }
+ }
+
decrypted, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshalling decrypted config: %w", err)
diff --git a/internal/inbox/models/models.go b/internal/inbox/models/models.go
index e70d53d4d..e8bc7e1fa 100644
--- a/internal/inbox/models/models.go
+++ b/internal/inbox/models/models.go
@@ -28,11 +28,16 @@ type Inbox struct {
Enabled bool `db:"enabled" json:"enabled"`
CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"`
PromptTagsOnReply bool `db:"prompt_tags_on_reply" json:"prompt_tags_on_reply"`
+ ReopenWindowHours int `db:"reopen_window_hours" json:"reopen_window_hours"`
From string `db:"from" json:"from"`
FromNameTemplate string `db:"from_name_template" json:"from_name_template"`
Config json.RawMessage `db:"config" json:"config"`
Secret null.String `db:"secret" json:"secret"`
LinkedEmailInboxID null.Int `db:"linked_email_inbox_id" json:"linked_email_inbox_id"`
+ // Computed, not persisted. The URL the admin pastes into Meta's webhook config.
+ WebhookURL string `db:"-" json:"webhook_url,omitempty"`
+ // Computed, not persisted. True when Meta recently rejected this inbox's access token.
+ TokenInvalid bool `db:"-" json:"token_invalid,omitempty"`
}
// Config holds the email inbox configuration with multiple SMTP servers and IMAP clients.
@@ -141,6 +146,22 @@ func (m *Inbox) ClearPasswords() error {
if m.Secret.Valid && m.Secret.String != "" {
m.Secret = null.StringFrom(strings.Repeat(stringutil.PasswordDummy, 10))
}
+ case "whatsapp":
+ var cfg map[string]any
+ if err := json.Unmarshal(m.Config, &cfg); err != nil {
+ return err
+ }
+ dummy := strings.Repeat(stringutil.PasswordDummy, 10)
+ for _, field := range []string{"access_token", "app_secret", "webhook_verify_token"} {
+ if v, ok := cfg[field].(string); ok && v != "" {
+ cfg[field] = dummy
+ }
+ }
+ cleared, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ m.Config = cleared
default:
return nil
}
diff --git a/internal/inbox/queries.sql b/internal/inbox/queries.sql
index 77d8a9844..03acd3ff6 100644
--- a/internal/inbox/queries.sql
+++ b/internal/inbox/queries.sql
@@ -1,24 +1,24 @@
-- name: get-active-inboxes
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, reopen_window_hours, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL;
-- name: get-all-inboxes
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, reopen_window_hours, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where deleted_at is NULL;
-- name: insert-inbox
INSERT INTO inboxes
-(channel, config, "name", "from", enabled, csat_enabled, prompt_tags_on_reply, secret, linked_email_inbox_id, from_name_template)
-VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+(channel, config, "name", "from", enabled, csat_enabled, prompt_tags_on_reply, reopen_window_hours, secret, linked_email_inbox_id, from_name_template)
+VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
-- name: get-inbox
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, reopen_window_hours, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL;
-- name: get-inbox-by-uuid
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, reopen_window_hours, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL;
-- name: update
UPDATE inboxes
-set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, prompt_tags_on_reply = $7, enabled = $8, secret = $9, linked_email_inbox_id = $10, from_name_template = $11, updated_at = now()
+set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, prompt_tags_on_reply = $7, reopen_window_hours = $8, enabled = $9, secret = $10, linked_email_inbox_id = $11, from_name_template = $12, updated_at = now()
where id = $1 and deleted_at is NULL
RETURNING *;
diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go
new file mode 100644
index 000000000..8dc9bffd0
--- /dev/null
+++ b/internal/migrations/v2.9.0.go
@@ -0,0 +1,119 @@
+package migrations
+
+import (
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/koanf/v2"
+ "github.com/knadh/stuffbin"
+)
+
+func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
+ _, err := db.Exec(`ALTER TYPE channels ADD VALUE IF NOT EXISTS 'whatsapp';`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS reopen_window_hours INT DEFAULT 0 NOT NULL;`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_inbound_at TIMESTAMPTZ NULL;`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_conversations_on_last_inbound_at ON conversations (last_inbound_at);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_resolved_at TIMESTAMPTZ NULL;`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`UPDATE conversations SET last_resolved_at = resolved_at WHERE resolved_at IS NOT NULL AND last_resolved_at IS NULL;`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS whatsapp_templates (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ inbox_id INT REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+ meta_template_id TEXT NULL,
+ name TEXT NOT NULL,
+ language TEXT NOT NULL,
+ category TEXT NOT NULL,
+ status TEXT DEFAULT 'PENDING' NOT NULL,
+ header_type TEXT NULL,
+ header_content TEXT NULL,
+ body_content TEXT NOT NULL,
+ footer_content TEXT NULL,
+ buttons JSONB DEFAULT '[]'::jsonb NOT NULL,
+ sample_values JSONB DEFAULT '{}'::jsonb NOT NULL,
+ rejection_reason TEXT NULL,
+ CONSTRAINT constraint_whatsapp_templates_on_name CHECK (length(name) <= 512),
+ CONSTRAINT constraint_whatsapp_templates_on_language CHECK (length(language) <= 20),
+ CONSTRAINT constraint_whatsapp_templates_on_category CHECK (length(category) <= 32),
+ CONSTRAINT constraint_whatsapp_templates_on_status CHECK (length(status) <= 32),
+ CONSTRAINT constraint_whatsapp_templates_on_header_type CHECK (length(header_type) <= 32)
+ );
+ `)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS index_unique_whatsapp_templates_on_inbox_name_language ON whatsapp_templates (inbox_id, name, language);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_whatsapp_templates_on_inbox_id ON whatsapp_templates (inbox_id);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_whatsapp_templates_on_meta_template_id ON whatsapp_templates (meta_template_id);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS contact_channel_identities (
+ id BIGSERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ contact_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+ channel channels NOT NULL,
+ identifier TEXT NOT NULL,
+ CONSTRAINT constraint_contact_channel_identities_on_identifier CHECK (length(identifier) <= 1000)
+ );
+ `)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS index_unique_contact_channel_identities_on_channel_identifier ON contact_channel_identities (channel, identifier);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_contact_channel_identities_on_contact_id ON contact_channel_identities (contact_id);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_tgrm_users_on_phone_number ON users USING GIN (phone_number gin_trgm_ops);`)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.Exec(`CREATE INDEX IF NOT EXISTS index_conversation_messages_on_source_id ON conversation_messages (source_id);`)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/search/models/models.go b/internal/search/models/models.go
index ea47ed22f..a49e649f4 100644
--- a/internal/search/models/models.go
+++ b/internal/search/models/models.go
@@ -24,10 +24,12 @@ type MessageResult struct {
}
type ContactResult struct {
- ID int `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- FirstName string `db:"first_name" json:"first_name"`
- LastName string `db:"last_name" json:"last_name"`
- Email string `db:"email" json:"email"`
- ExternalUserID null.String `db:"external_user_id" json:"external_user_id"`
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ FirstName string `db:"first_name" json:"first_name"`
+ LastName string `db:"last_name" json:"last_name"`
+ Email null.String `db:"email" json:"email"`
+ PhoneNumber null.String `db:"phone_number" json:"phone_number"`
+ PhoneNumberCountryCode null.String `db:"phone_number_country_code" json:"phone_number_country_code"`
+ ExternalUserID null.String `db:"external_user_id" json:"external_user_id"`
}
diff --git a/internal/search/queries.sql b/internal/search/queries.sql
index da9c06b3b..5b4ae89b7 100644
--- a/internal/search/queries.sql
+++ b/internal/search/queries.sql
@@ -43,9 +43,11 @@ SELECT
first_name,
last_name,
email,
+ phone_number,
+ phone_number_country_code,
external_user_id
FROM users
WHERE type = 'contact'
AND deleted_at IS NULL
-AND email ILIKE '%' || $1 || '%'
+AND (email ILIKE '%' || $1 || '%' OR phone_number ILIKE '%' || $1 || '%')
LIMIT 15;
diff --git a/internal/streamqueue/streamqueue.go b/internal/streamqueue/streamqueue.go
new file mode 100644
index 000000000..148354844
--- /dev/null
+++ b/internal/streamqueue/streamqueue.go
@@ -0,0 +1,300 @@
+// Package streamqueue is a durable, at-least-once work queue backed by Redis Streams; handlers MUST be idempotent.
+package streamqueue
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/zerodha/logf"
+)
+
+const (
+ defaultWorkers = 4
+ defaultMaxAttempts = 50
+ defaultMaxLen = 100000
+ defaultClaimMinIdle = 30 * time.Second
+ defaultReclaimEvery = 15 * time.Second
+ defaultBlock = 5 * time.Second
+ defaultBatch = 16
+ defaultAckTimeout = 5 * time.Second
+
+ deadLetterWarnThresh = 1000
+
+ payloadField = "payload"
+ origIDField = "orig_id"
+)
+
+// Handler processes one entry; a non-nil error leaves the entry pending for retry, nil acknowledges it.
+type Handler func(ctx context.Context, payload []byte) error
+
+// Opts configures a Queue. Redis, Stream, Group, Handler and Logger are required.
+type Opts struct {
+ Redis *redis.Client
+ Logger *logf.Logger
+ Handler Handler
+ Stream string
+ Group string
+ Consumer string
+ Workers int
+ MaxAttempts int
+ ClaimMinIdle time.Duration
+}
+
+// Queue is a single consumer group over one Redis stream, with a dead-letter stream for poison entries.
+type Queue struct {
+ rd *redis.Client
+ lo *logf.Logger
+ handler Handler
+ stream string
+ deadStream string
+ group string
+ consumer string
+ workers int
+ maxAttempts int
+ claimMinIdle time.Duration
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// New creates the consumer group (and stream) if absent and returns a ready Queue. Call Run to start consuming.
+func New(opts Opts) (*Queue, error) {
+ if opts.Redis == nil {
+ return nil, errors.New("streamqueue: redis client is required")
+ }
+ if opts.Stream == "" || opts.Group == "" {
+ return nil, errors.New("streamqueue: stream and group are required")
+ }
+ if opts.Handler == nil {
+ return nil, errors.New("streamqueue: handler is required")
+ }
+ if opts.Logger == nil {
+ return nil, errors.New("streamqueue: logger is required")
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ q := &Queue{
+ rd: opts.Redis,
+ lo: opts.Logger,
+ handler: opts.Handler,
+ stream: opts.Stream,
+ deadStream: opts.Stream + ":dead",
+ group: opts.Group,
+ consumer: cmp.Or(opts.Consumer, "consumer"),
+ workers: positiveOr(opts.Workers, defaultWorkers),
+ maxAttempts: positiveOr(opts.MaxAttempts, defaultMaxAttempts),
+ claimMinIdle: positiveOr(opts.ClaimMinIdle, defaultClaimMinIdle),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ if err := q.rd.XGroupCreateMkStream(ctx, q.stream, q.group, "0").Err(); err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
+ cancel()
+ return nil, fmt.Errorf("streamqueue: creating consumer group: %w", err)
+ }
+ return q, nil
+}
+
+// Enqueue appends a payload to the stream. A nil error means the entry is durably stored.
+func (q *Queue) Enqueue(ctx context.Context, payload []byte) error {
+ return q.rd.XAdd(ctx, &redis.XAddArgs{
+ Stream: q.stream,
+ MaxLen: defaultMaxLen,
+ Approx: true,
+ Values: map[string]any{payloadField: payload},
+ }).Err()
+}
+
+// Run starts the workers and the reclaimer and blocks until Close is called.
+func (q *Queue) Run() {
+ for i := range q.workers {
+ q.wg.Add(1)
+ go q.worker(fmt.Sprintf("%s:%d", q.consumer, i))
+ }
+ q.wg.Add(1)
+ go q.reclaimer()
+ q.wg.Wait()
+}
+
+// Close stops consuming and waits for in-flight handlers to finish; un-acked entries stay durable for the next start.
+func (q *Queue) Close() {
+ q.cancel()
+ q.wg.Wait()
+}
+
+func (q *Queue) worker(consumer string) {
+ defer q.wg.Done()
+ for {
+ if q.ctx.Err() != nil {
+ return
+ }
+ res, err := q.rd.XReadGroup(q.ctx, &redis.XReadGroupArgs{
+ Group: q.group,
+ Consumer: consumer,
+ Streams: []string{q.stream, ">"},
+ Count: defaultBatch,
+ Block: defaultBlock,
+ }).Result()
+ if err != nil {
+ if errors.Is(err, redis.Nil) || q.ctx.Err() != nil {
+ continue
+ }
+ q.lo.Error("error reading from stream", "stream", q.stream, "error", err)
+ q.sleep(time.Second)
+ continue
+ }
+ for _, s := range res {
+ for _, msg := range s.Messages {
+ if q.ctx.Err() != nil {
+ return
+ }
+ q.process(consumer, msg)
+ }
+ }
+ }
+}
+
+func (q *Queue) reclaimer() {
+ defer q.wg.Done()
+ t := time.NewTicker(defaultReclaimEvery)
+ defer t.Stop()
+ for {
+ select {
+ case <-q.ctx.Done():
+ return
+ case <-t.C:
+ q.reclaimOnce()
+ }
+ }
+}
+
+func (q *Queue) reclaimOnce() {
+ pending, err := q.rd.XPendingExt(q.ctx, &redis.XPendingExtArgs{
+ Stream: q.stream,
+ Group: q.group,
+ Idle: q.claimMinIdle,
+ Start: "-",
+ End: "+",
+ Count: defaultBatch,
+ }).Result()
+ if err != nil {
+ if !errors.Is(err, redis.Nil) && q.ctx.Err() == nil {
+ q.lo.Error("error scanning pending stream entries", "stream", q.stream, "error", err)
+ }
+ return
+ }
+
+ var retry, dead []string
+ for _, p := range pending {
+ if int(p.RetryCount) >= q.maxAttempts {
+ dead = append(dead, p.ID)
+ } else {
+ retry = append(retry, p.ID)
+ }
+ }
+
+ if len(dead) > 0 {
+ q.deadLetter(dead)
+ }
+ if len(retry) == 0 {
+ return
+ }
+
+ msgs, err := q.rd.XClaim(q.ctx, &redis.XClaimArgs{
+ Stream: q.stream,
+ Group: q.group,
+ Consumer: q.consumer + ":reclaim",
+ MinIdle: q.claimMinIdle,
+ Messages: retry,
+ }).Result()
+ if err != nil {
+ if q.ctx.Err() == nil {
+ q.lo.Error("error claiming pending stream entries", "stream", q.stream, "error", err)
+ }
+ return
+ }
+ for _, msg := range msgs {
+ if q.ctx.Err() != nil {
+ return
+ }
+ q.process(q.consumer+":reclaim", msg)
+ }
+}
+
+func (q *Queue) deadLetter(ids []string) {
+ msgs, err := q.rd.XClaim(q.ctx, &redis.XClaimArgs{
+ Stream: q.stream,
+ Group: q.group,
+ Consumer: q.consumer + ":dead",
+ MinIdle: q.claimMinIdle,
+ Messages: ids,
+ }).Result()
+ if err != nil {
+ if q.ctx.Err() == nil {
+ q.lo.Error("error claiming dead stream entries", "stream", q.stream, "error", err)
+ }
+ return
+ }
+ for _, msg := range msgs {
+ if err := q.rd.XAdd(q.ctx, &redis.XAddArgs{
+ Stream: q.deadStream,
+ Values: map[string]any{payloadField: msg.Values[payloadField], origIDField: msg.ID},
+ }).Err(); err != nil {
+ q.lo.Error("error moving entry to dead-letter stream", "stream", q.stream, "dead_stream", q.deadStream, "id", msg.ID, "error", err)
+ continue
+ }
+ if err := q.rd.XAck(q.ctx, q.stream, q.group, msg.ID).Err(); err == nil {
+ q.rd.XDel(q.ctx, q.stream, msg.ID)
+ }
+ q.lo.Warn("stream entry dead-lettered, check and fix the issue", "stream", q.stream, "dead_stream", q.deadStream, "id", msg.ID, "max_attempts", q.maxAttempts)
+ }
+ deadLetterLen := q.rd.XLen(q.ctx, q.deadStream).Val()
+ if deadLetterLen > deadLetterWarnThresh {
+ q.lo.Warn("DEAD-LETTER QUEUE OVERFLOW - ENTRIES CONSUMING REDIS MEMORY", "stream", q.stream, "dead_stream", q.deadStream, "dead_letter_count", deadLetterLen, "threshold", deadLetterWarnThresh)
+ }
+}
+
+func (q *Queue) process(consumer string, msg redis.XMessage) {
+ payload, _ := msg.Values[payloadField].(string)
+ if err := q.invoke([]byte(payload)); err != nil {
+ q.lo.Warn("stream handler failed, entry left pending for retry", "stream", q.stream, "consumer", consumer, "id", msg.ID, "error", err)
+ return
+ }
+ // Ack on a detached context so a handler that finished as shutdown cancelled q.ctx still releases the entry.
+ ackCtx, cancel := context.WithTimeout(context.Background(), defaultAckTimeout)
+ defer cancel()
+ if err := q.rd.XAck(ackCtx, q.stream, q.group, msg.ID).Err(); err != nil {
+ q.lo.Error("error acking stream entry", "stream", q.stream, "id", msg.ID, "error", err)
+ return
+ }
+ q.rd.XDel(ackCtx, q.stream, msg.ID)
+}
+
+func (q *Queue) invoke(payload []byte) (err error) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ err = fmt.Errorf("streamqueue: handler panic: %v", rec)
+ }
+ }()
+ return q.handler(q.ctx, payload)
+}
+
+func (q *Queue) sleep(d time.Duration) {
+ select {
+ case <-q.ctx.Done():
+ case <-time.After(d):
+ }
+}
+
+func positiveOr[T ~int | ~int64](v, fallback T) T {
+ if v <= 0 {
+ return fallback
+ }
+ return v
+}
diff --git a/internal/streamqueue/streamqueue_test.go b/internal/streamqueue/streamqueue_test.go
new file mode 100644
index 000000000..4aceb38ee
--- /dev/null
+++ b/internal/streamqueue/streamqueue_test.go
@@ -0,0 +1,293 @@
+package streamqueue
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/redis/go-redis/v9"
+ "github.com/zerodha/logf"
+)
+
+func TestQueueProcessesAndAcks(t *testing.T) {
+ mr := miniredis.RunT(t)
+ var got int64
+ done := make(chan struct{}, 3)
+ q := testQueue(t, mr, Opts{
+ Workers: 2,
+ Handler: func(_ context.Context, _ []byte) error {
+ atomic.AddInt64(&got, 1)
+ done <- struct{}{}
+ return nil
+ },
+ })
+
+ for range 3 {
+ if err := q.Enqueue(context.Background(), []byte("x")); err != nil {
+ t.Fatalf("Enqueue: %v", err)
+ }
+ }
+
+ go q.Run()
+ defer q.Close()
+
+ for range 3 {
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatalf("timed out, processed %d/3", atomic.LoadInt64(&got))
+ }
+ }
+ if n := atomic.LoadInt64(&got); n != 3 {
+ t.Fatalf("processed %d, want 3", n)
+ }
+ waitFor(t, func() bool { return pendingCount(t, q) == 0 }, "pending should drain to 0")
+}
+
+func TestQueueRetriesPendingUntilSuccess(t *testing.T) {
+ mr := miniredis.RunT(t)
+ var attempts int64
+ q := testQueue(t, mr, Opts{
+ Workers: 1,
+ ClaimMinIdle: 10 * time.Millisecond,
+ Handler: func(_ context.Context, _ []byte) error {
+ if atomic.AddInt64(&attempts, 1) < 3 {
+ return errors.New("transient failure")
+ }
+ return nil
+ },
+ })
+
+ if err := q.Enqueue(context.Background(), []byte("x")); err != nil {
+ t.Fatalf("Enqueue: %v", err)
+ }
+
+ msg := readOne(t, q, "w0")
+ q.process("w0", msg)
+ if pendingCount(t, q) != 1 {
+ t.Fatalf("entry should stay pending after a failed handler")
+ }
+
+ for range 2 {
+ time.Sleep(25 * time.Millisecond)
+ q.reclaimOnce()
+ }
+
+ if n := atomic.LoadInt64(&attempts); n != 3 {
+ t.Fatalf("attempts = %d, want 3 (1 initial + 2 reclaim)", n)
+ }
+ if c := pendingCount(t, q); c != 0 {
+ t.Fatalf("pending = %d, want 0 after success", c)
+ }
+}
+
+func TestQueueDeadLettersAfterMaxAttempts(t *testing.T) {
+ mr := miniredis.RunT(t)
+ var attempts int64
+ q := testQueue(t, mr, Opts{
+ Workers: 1,
+ MaxAttempts: 1,
+ ClaimMinIdle: 10 * time.Millisecond,
+ Handler: func(_ context.Context, _ []byte) error {
+ atomic.AddInt64(&attempts, 1)
+ return errors.New("always fails")
+ },
+ })
+
+ if err := q.Enqueue(context.Background(), []byte("poison")); err != nil {
+ t.Fatalf("Enqueue: %v", err)
+ }
+
+ msg := readOne(t, q, "w0")
+ q.process("w0", msg)
+
+ for range 4 {
+ time.Sleep(25 * time.Millisecond)
+ q.reclaimOnce()
+ if pendingCount(t, q) == 0 {
+ break
+ }
+ }
+
+ if c := pendingCount(t, q); c != 0 {
+ t.Fatalf("pending = %d, want 0 after dead-letter", c)
+ }
+ if n := atomic.LoadInt64(&attempts); n != 1 {
+ t.Fatalf("handler ran %d times, want 1 (MaxAttempts=1 must be honored exactly)", n)
+ }
+ deadLen, err := q.rd.XLen(context.Background(), q.deadStream).Result()
+ if err != nil {
+ t.Fatalf("XLen dead: %v", err)
+ }
+ if deadLen != 1 {
+ t.Fatalf("dead-letter stream length = %d, want 1", deadLen)
+ }
+}
+
+func TestQueueSurvivesRestart(t *testing.T) {
+ mr := miniredis.RunT(t)
+ addr := mr.Addr()
+ stream, group := "test:restart", "g"
+
+ producer, err := New(Opts{
+ Redis: redis.NewClient(&redis.Options{Addr: addr}), Logger: ptrLogger(),
+ Stream: stream, Group: group, Handler: func(context.Context, []byte) error { return nil },
+ })
+ if err != nil {
+ t.Fatalf("New producer: %v", err)
+ }
+ if err := producer.Enqueue(context.Background(), []byte("queued-before-start")); err != nil {
+ t.Fatalf("Enqueue: %v", err)
+ }
+
+ var got []byte
+ var mu sync.Mutex
+ processed := make(chan struct{}, 1)
+ consumer := testQueue(t, mr, Opts{
+ Stream: stream, Group: group, Workers: 1,
+ Handler: func(_ context.Context, payload []byte) error {
+ mu.Lock()
+ got = append([]byte(nil), payload...)
+ mu.Unlock()
+ processed <- struct{}{}
+ return nil
+ },
+ })
+ go consumer.Run()
+ defer consumer.Close()
+
+ select {
+ case <-processed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("entry enqueued before the consumer started was never processed")
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if string(got) != "queued-before-start" {
+ t.Fatalf("got %q, want %q", got, "queued-before-start")
+ }
+}
+
+func waitFor(t *testing.T, cond func() bool, msg string) {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatalf("condition not met: %s", msg)
+}
+
+func TestNewValidatesOpts(t *testing.T) {
+ mr := miniredis.RunT(t)
+ rd := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ handler := func(context.Context, []byte) error { return nil }
+
+ tests := []struct {
+ name string
+ opts Opts
+ }{
+ {"no redis", Opts{Stream: "s", Group: "g", Handler: handler, Logger: ptrLogger()}},
+ {"no stream", Opts{Redis: rd, Group: "g", Handler: handler, Logger: ptrLogger()}},
+ {"no group", Opts{Redis: rd, Stream: "s", Handler: handler, Logger: ptrLogger()}},
+ {"no handler", Opts{Redis: rd, Stream: "s", Group: "g", Logger: ptrLogger()}},
+ {"no logger", Opts{Redis: rd, Stream: "s", Group: "g", Handler: handler}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := New(tc.opts); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+ }
+}
+
+func TestNewFailsWhenRedisIsUnreachable(t *testing.T) {
+ mr := miniredis.RunT(t)
+ rd := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ mr.Close()
+ _, err := New(Opts{Redis: rd, Stream: "s", Group: "g", Handler: func(context.Context, []byte) error { return nil }, Logger: ptrLogger()})
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+// A panicking handler must leave the entry pending rather than take the worker down.
+func TestHandlerPanicIsContained(t *testing.T) {
+ mr := miniredis.RunT(t)
+ q := testQueue(t, mr, Opts{Handler: func(context.Context, []byte) error { panic("boom") }})
+ defer q.Close()
+
+ if err := q.Enqueue(context.Background(), []byte("payload")); err != nil {
+ t.Fatalf("enqueue: %v", err)
+ }
+ msg := readOne(t, q, "worker")
+ q.process("worker", msg)
+
+ if got := pendingCount(t, q); got != 1 {
+ t.Fatalf("expected the entry to stay pending, got %d", got)
+ }
+}
+
+func TestSleepReturnsOnClose(t *testing.T) {
+ mr := miniredis.RunT(t)
+ q := testQueue(t, mr, Opts{Handler: func(context.Context, []byte) error { return nil }})
+
+ q.cancel()
+ start := time.Now()
+ q.sleep(5 * time.Second)
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Fatalf("sleep ignored the cancelled context, waited %s", elapsed)
+ }
+}
+
+func testQueue(t *testing.T, mr *miniredis.Miniredis, opts Opts) *Queue {
+ t.Helper()
+ opts.Redis = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ opts.Logger = ptrLogger()
+ if opts.Stream == "" {
+ opts.Stream = "test:stream"
+ }
+ if opts.Group == "" {
+ opts.Group = "test:group"
+ }
+ q, err := New(opts)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return q
+}
+
+func ptrLogger() *logf.Logger {
+ l := logf.New(logf.Opts{Level: logf.FatalLevel})
+ return &l
+}
+
+func readOne(t *testing.T, q *Queue, consumer string) redis.XMessage {
+ t.Helper()
+ res, err := q.rd.XReadGroup(context.Background(), &redis.XReadGroupArgs{
+ Group: q.group, Consumer: consumer, Streams: []string{q.stream, ">"}, Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatalf("XReadGroup: %v", err)
+ }
+ if len(res) == 0 || len(res[0].Messages) == 0 {
+ t.Fatalf("expected one message, got none")
+ }
+ return res[0].Messages[0]
+}
+
+func pendingCount(t *testing.T, q *Queue) int64 {
+ t.Helper()
+ p, err := q.rd.XPending(context.Background(), q.stream, q.group).Result()
+ if err != nil {
+ t.Fatalf("XPending: %v", err)
+ }
+ return p.Count
+}
diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go
index 8d7daac08..89d3fae59 100644
--- a/internal/stringutil/stringutil.go
+++ b/internal/stringutil/stringutil.go
@@ -38,6 +38,18 @@ var (
)
)
+// NormalizeWhatsAppPhone strips formatting to the bare digit string Meta uses as the wa_id.
+func NormalizeWhatsAppPhone(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for _, r := range s {
+ if r >= '0' && r <= '9' {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
// SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character.
func SanitizeUTF8(s string) string {
if s == "" {
diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go
new file mode 100644
index 000000000..37aa6a1cd
--- /dev/null
+++ b/internal/testdb/testdb.go
@@ -0,0 +1,98 @@
+// Package testdb loads schema.sql into a test database, or skips the test when LIBREDESK_TEST_DB_DSN is unset (see `make test-db`).
+package testdb
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/jmoiron/sqlx"
+ _ "github.com/lib/pq"
+)
+
+const dsnEnvVar = "LIBREDESK_TEST_DB_DSN"
+
+var (
+ mu sync.Mutex
+ loaded = map[string]*sqlx.DB{}
+ skipMsg = fmt.Sprintf("set %s to run database tests (see `make test-db`)", dsnEnvVar)
+)
+
+// New loads schema.sql into a database named after suffix. Each package needs its own, since schema.sql drops every table.
+func New(t testing.TB, suffix string) *sqlx.DB {
+ t.Helper()
+
+ dsn := strings.TrimSpace(os.Getenv(dsnEnvVar))
+ if dsn == "" {
+ t.Skip(skipMsg)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if db, ok := loaded[suffix]; ok {
+ return db
+ }
+
+ admin, err := sqlx.Connect("postgres", dsn)
+ if err != nil {
+ t.Skipf("%s: %v", skipMsg, err)
+ }
+ defer admin.Close()
+
+ name := "libredesk_test_" + suffix
+ if _, err := admin.Exec(`CREATE DATABASE ` + pq(name)); err != nil && !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("creating test database %s: %v", name, err)
+ }
+
+ db, err := sqlx.Connect("postgres", swapDatabase(t, dsn, name))
+ if err != nil {
+ t.Fatalf("connecting to test database %s: %v", name, err)
+ }
+ if _, err := db.Exec(schema(t)); err != nil {
+ t.Fatalf("loading schema.sql: %v", err)
+ }
+
+ loaded[suffix] = db
+ return db
+}
+
+func swapDatabase(t testing.TB, dsn, name string) string {
+ t.Helper()
+ u, err := url.Parse(dsn)
+ if err != nil {
+ t.Fatalf("parsing %s: %v", dsnEnvVar, err)
+ }
+ u.Path = "/" + name
+ return u.String()
+}
+
+func schema(t testing.TB) string {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ for {
+ candidate := filepath.Join(dir, "schema.sql")
+ if _, err := os.Stat(candidate); err == nil {
+ raw, err := os.ReadFile(candidate)
+ if err != nil {
+ t.Fatalf("reading %s: %v", candidate, err)
+ }
+ return string(raw)
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal("could not find schema.sql above the test's directory")
+ }
+ dir = parent
+ }
+}
+
+func pq(identifier string) string {
+ return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
+}
diff --git a/internal/user/contact.go b/internal/user/contact.go
index c02bd6ce0..dc33574a0 100644
--- a/internal/user/contact.go
+++ b/internal/user/contact.go
@@ -207,3 +207,127 @@ func (u *Manager) newContactPassword() ([]byte, error) {
}
return password, nil
}
+
+func (u *Manager) GetContactIDByChannelIdentity(channel, identifier string) (int, error) {
+ var id int
+ if err := u.q.GetContactIDByChannelIdentity.Get(&id, channel, identifier); err != nil {
+ if err == sql.ErrNoRows {
+ return 0, envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil)
+ }
+ u.lo.Error("error fetching contact by channel identity", "channel", channel, "error", err)
+ return 0, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return id, nil
+}
+
+func (u *Manager) LinkChannelIdentity(contactID int, channel, identifier string) (int, error) {
+ var linkedID int
+ if err := u.q.InsertChannelIdentity.QueryRow(contactID, channel, identifier).Scan(&linkedID); err != nil {
+ u.lo.Error("error linking channel identity", "contact_id", contactID, "channel", channel, "error", err)
+ return 0, fmt.Errorf("linking channel identity: %w", err)
+ }
+ return linkedID, nil
+}
+
+// UpdateChannelIdentity returns the contact id, or 0 when the new identifier already belongs to a contact.
+func (u *Manager) UpdateChannelIdentity(channel, oldIdentifier, newIdentifier string) (int, error) {
+ var contactID int
+ if err := u.q.UpdateChannelIdentity.QueryRow(channel, oldIdentifier, newIdentifier).Scan(&contactID); err != nil {
+ if err == sql.ErrNoRows {
+ return 0, nil
+ }
+ u.lo.Error("error updating channel identity", "channel", channel, "error", err)
+ return 0, fmt.Errorf("updating channel identity: %w", err)
+ }
+ return contactID, nil
+}
+
+func (u *Manager) UpsertContactByChannelIdentity(channel, identifier string, contact *models.User) (int, error) {
+ id, err := u.GetContactIDByChannelIdentity(channel, identifier)
+ if err == nil {
+ return id, nil
+ }
+ if envErr, ok := err.(envelope.Error); !ok || envErr.ErrorType != envelope.NotFoundError {
+ return 0, err
+ }
+ // A contact with no email and no ext_id has no uniqueness key, so the non-atomic resolve + link orphans user rows on retry.
+ if contact.Email.String == "" && contact.ExternalUserID.String == "" {
+ return u.upsertContactWithChannelIdentity(channel, identifier, contact)
+ }
+ if err := u.ResolveContact(contact, models.ContactSync); err != nil {
+ return 0, err
+ }
+ return u.LinkChannelIdentity(contact.ID, channel, identifier)
+}
+
+func (u *Manager) upsertContactWithChannelIdentity(channel, identifier string, contact *models.User) (int, error) {
+ password, err := u.generatePassword()
+ if err != nil {
+ return 0, fmt.Errorf("generating password: %w", err)
+ }
+ var (
+ id int
+ insertedID sql.NullInt64
+ )
+ if err := u.q.UpsertContactWithChannelIdentity.QueryRow(
+ contact.Email, contact.FirstName, contact.LastName, password, contact.AvatarURL,
+ channel, identifier,
+ ).Scan(&id, &insertedID); err != nil {
+ u.lo.Error("error upserting contact with channel identity", "channel", channel, "identifier", identifier, "error", err)
+ return 0, fmt.Errorf("upserting contact with channel identity: %w", err)
+ }
+ // A concurrent upsert can win the identity insert; the row this statement created is then orphaned.
+ if insertedID.Valid && int(insertedID.Int64) != id {
+ if _, err := u.q.DeleteOrphanedContact.Exec(insertedID.Int64); err != nil {
+ u.lo.Error("error deleting orphaned contact after identity race", "user_id", insertedID.Int64, "error", err)
+ }
+ }
+ contact.ID = id
+ return id, nil
+}
+
+// SetContactPhoneIfMissing sets phone_number only when it is empty, never clobbering an agent-curated value.
+func (u *Manager) SetContactPhoneIfMissing(id int, phone, countryCode string) error {
+ if id == 0 || phone == "" {
+ return nil
+ }
+ if _, err := u.q.SetContactPhoneIfMissing.Exec(id, phone, countryCode); err != nil {
+ u.lo.Error("error setting contact phone number", "id", id, "error", err)
+ return fmt.Errorf("setting contact phone number: %w", err)
+ }
+ return nil
+}
+
+func (u *Manager) GetChannelIdentities(contactID int) ([]models.ChannelIdentity, error) {
+ out := make([]models.ChannelIdentity, 0)
+ if err := u.q.GetChannelIdentitiesByContact.Select(&out, contactID); err != nil {
+ u.lo.Error("error fetching channel identities", "contact_id", contactID, "error", err)
+ return nil, fmt.Errorf("fetching channel identities: %w", err)
+ }
+ return out, nil
+}
+
+// GetChannelIdentity returns the contact's identifier on a channel, "" with nil error when none.
+func (u *Manager) GetChannelIdentity(contactID int, channel string) (string, error) {
+ var identifier string
+ if err := u.q.GetChannelIdentity.Get(&identifier, contactID, channel); err != nil {
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ u.lo.Error("error fetching channel identity", "contact_id", contactID, "channel", channel, "error", err)
+ return "", fmt.Errorf("fetching channel identity: %w", err)
+ }
+ return identifier, nil
+}
+
+// UpdateContactNameIfDefault replaces the name only while it still equals defaultName, never over agent edits.
+func (u *Manager) UpdateContactNameIfDefault(id int, firstName, lastName, defaultName string) error {
+ if id == 0 || firstName == "" {
+ return nil
+ }
+ if _, err := u.q.UpdateContactNameIfDefault.Exec(id, firstName, lastName, defaultName); err != nil {
+ u.lo.Error("error updating contact name", "id", id, "error", err)
+ return fmt.Errorf("updating contact name: %w", err)
+ }
+ return nil
+}
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index f22a4bae6..95eb87313 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -2,6 +2,7 @@ package models
import (
"encoding/json"
+ "fmt"
"slices"
"time"
@@ -92,6 +93,29 @@ type User struct {
APIKey null.String `db:"api_key" json:"api_key"`
APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"`
APISecret null.String `db:"api_secret" json:"-"`
+
+ ChannelIdentities ChannelIdentities `db:"channel_identities" json:"channel_identities,omitempty"`
+}
+
+// ChannelIdentity is a per-channel identifier (e.g. a WhatsApp phone) linked to a contact.
+type ChannelIdentity struct {
+ Channel string `db:"channel" json:"channel"`
+ Identifier string `db:"identifier" json:"identifier"`
+}
+
+type ChannelIdentities []ChannelIdentity
+
+func (c *ChannelIdentities) Scan(src interface{}) error {
+ if src == nil {
+ *c = nil
+ return nil
+ }
+ switch v := src.(type) {
+ case []byte:
+ return json.Unmarshal(v, c)
+ default:
+ return fmt.Errorf("unsupported type for ChannelIdentities: %T", src)
+ }
}
// ChatUser is a user with limited fields for live chat.
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index bc8b5c892..a3a665c38 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -62,7 +62,7 @@ LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
WHERE u.deleted_at IS NULL
AND ($1 = 0 OR u.id = $1)
AND ($2 = '' OR u.email = $2)
- AND (cardinality($3::text[]) = 0 OR u.type::text = ANY($3::text[]))
+ AND (COALESCE(cardinality($3::text[]), 0) = 0 OR u.type::text = ANY($3::text[]))
GROUP BY u.id
ORDER BY u.id ASC
LIMIT 1;
@@ -213,6 +213,76 @@ SELECT EXISTS(
UPDATE users SET external_user_id = $2, updated_at = now()
WHERE id = $1 AND type = 'contact' AND deleted_at IS NULL;
+-- name: set-contact-phone-if-missing
+UPDATE users SET phone_number = $2, phone_number_country_code = NULLIF($3, ''), updated_at = now()
+WHERE id = $1
+ AND type IN ('contact', 'visitor')
+ AND deleted_at IS NULL
+ AND (phone_number IS NULL OR phone_number = '');
+
+-- name: update-contact-name-if-default
+UPDATE users SET first_name = $2, last_name = $3, updated_at = now()
+WHERE id = $1
+ AND type = 'contact'
+ AND deleted_at IS NULL
+ AND first_name = $4
+ AND COALESCE(last_name, '') = '';
+
+-- name: get-contact-id-by-channel-identity
+SELECT contact_id FROM contact_channel_identities WHERE channel = $1::channels AND identifier = $2;
+
+-- name: get-channel-identities-by-contact
+SELECT channel, identifier FROM contact_channel_identities WHERE contact_id = $1 ORDER BY id;
+
+-- name: get-channel-identity
+SELECT identifier FROM contact_channel_identities WHERE contact_id = $1 AND channel = $2::channels ORDER BY id LIMIT 1;
+
+-- name: insert-channel-identity
+INSERT INTO contact_channel_identities (contact_id, channel, identifier)
+VALUES ($1, $2::channels, $3)
+ON CONFLICT (channel, identifier) DO UPDATE SET updated_at = now()
+RETURNING contact_id;
+
+-- name: update-channel-identity
+-- $1=channel, $2=old identifier, $3=new identifier. No-op when the new identifier already belongs to a contact.
+UPDATE contact_channel_identities SET identifier = $3, updated_at = now()
+WHERE channel = $1::channels AND identifier = $2
+AND NOT EXISTS (
+ SELECT 1 FROM contact_channel_identities WHERE channel = $1::channels AND identifier = $3
+)
+RETURNING contact_id;
+
+-- name: upsert-contact-with-channel-identity
+-- Atomic: a contact with no email and no ext_id has no uniqueness key, so a separate insert + link would orphan user rows on retry.
+-- $1=email, $2=first_name, $3=last_name, $4=password, $5=avatar_url, $6=channel, $7=identifier
+WITH existing AS (
+ SELECT contact_id FROM contact_channel_identities
+ WHERE channel = $6::channels AND identifier = $7
+),
+new_contact AS (
+ INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id)
+ SELECT $1, 'contact', $2, $3, $4, $5, NULL
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
+ RETURNING id
+),
+new_identity AS (
+ INSERT INTO contact_channel_identities (contact_id, channel, identifier)
+ SELECT id, $6::channels, $7 FROM new_contact
+ ON CONFLICT (channel, identifier) DO UPDATE SET updated_at = now()
+ RETURNING contact_id
+)
+SELECT COALESCE(
+ (SELECT contact_id FROM new_identity),
+ (SELECT contact_id FROM existing)
+) AS id,
+(SELECT id FROM new_contact) AS inserted_id;
+
+-- name: delete-orphaned-contact
+-- Cleans up the losing row of a concurrent identity upsert; the guard keeps it a no-op for any contact that gained an identity.
+DELETE FROM users
+WHERE id = $1 AND type = 'contact'
+AND NOT EXISTS (SELECT 1 FROM contact_channel_identities WHERE contact_id = $1);
+
-- name: insert-visitor
INSERT INTO users (email, type, first_name, last_name, custom_attributes, phone_number, phone_number_country_code)
VALUES ($1, 'visitor', $2, $3, $4, $5, $6)
diff --git a/internal/user/user.go b/internal/user/user.go
index ee359c81e..f1407bb4a 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -78,41 +78,50 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
- GetUser *sqlx.Stmt `query:"get-user"`
- GetNotes *sqlx.Stmt `query:"get-notes"`
- GetNote *sqlx.Stmt `query:"get-note"`
- GetUserIDsByRole *sqlx.Stmt `query:"get-user-ids-by-role"`
- GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"`
- GetUsersCompact string `query:"get-users-compact"`
- UpdateContact *sqlx.Stmt `query:"update-contact"`
- UpdateContactBasicInfo *sqlx.Stmt `query:"update-contact-basic-info"`
- UpdateAgent *sqlx.Stmt `query:"update-agent"`
- UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
- UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"`
- UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
- UpdateAvailability *sqlx.Stmt `query:"update-availability"`
- UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
- UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
- GetAvailabilityStatus *sqlx.Stmt `query:"get-availability-status"`
- UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
- SoftDeleteAgent *sqlx.Stmt `query:"soft-delete-agent"`
- SetUserPassword *sqlx.Stmt `query:"set-user-password"`
- SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
- SetPassword *sqlx.Stmt `query:"set-password"`
- DeleteNote *sqlx.Stmt `query:"delete-note"`
- InsertAgent *sqlx.Stmt `query:"insert-agent"`
- InsertContactWithExtID *sqlx.Stmt `query:"insert-contact-with-external-id"`
- InsertContactNoExtID *sqlx.Stmt `query:"insert-contact-without-external-id"`
- InsertContactIfAbsent *sqlx.Stmt `query:"insert-contact-if-absent"`
- GetContactByEmail *sqlx.Stmt `query:"get-contact-by-email"`
- GetContactByEmailWithoutExtID *sqlx.Stmt `query:"get-contact-by-email-without-ext-id"`
- IsEmailBlocked *sqlx.Stmt `query:"is-email-blocked"`
- SetExternalUserID *sqlx.Stmt `query:"set-external-user-id"`
- InsertNote *sqlx.Stmt `query:"insert-note"`
- InsertVisitor *sqlx.Stmt `query:"insert-visitor"`
- GetVisitorByEmail *sqlx.Stmt `query:"get-visitor-by-email"`
- UpgradeVisitorToContact *sqlx.Stmt `query:"upgrade-visitor-to-contact"`
- ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
+ GetUser *sqlx.Stmt `query:"get-user"`
+ GetNotes *sqlx.Stmt `query:"get-notes"`
+ GetNote *sqlx.Stmt `query:"get-note"`
+ GetUserIDsByRole *sqlx.Stmt `query:"get-user-ids-by-role"`
+ GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"`
+ GetUsersCompact string `query:"get-users-compact"`
+ UpdateContact *sqlx.Stmt `query:"update-contact"`
+ UpdateContactBasicInfo *sqlx.Stmt `query:"update-contact-basic-info"`
+ UpdateAgent *sqlx.Stmt `query:"update-agent"`
+ UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
+ UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"`
+ UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
+ UpdateAvailability *sqlx.Stmt `query:"update-availability"`
+ UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
+ UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
+ GetAvailabilityStatus *sqlx.Stmt `query:"get-availability-status"`
+ UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
+ SoftDeleteAgent *sqlx.Stmt `query:"soft-delete-agent"`
+ SetUserPassword *sqlx.Stmt `query:"set-user-password"`
+ SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
+ SetPassword *sqlx.Stmt `query:"set-password"`
+ DeleteNote *sqlx.Stmt `query:"delete-note"`
+ InsertAgent *sqlx.Stmt `query:"insert-agent"`
+ InsertContactWithExtID *sqlx.Stmt `query:"insert-contact-with-external-id"`
+ InsertContactNoExtID *sqlx.Stmt `query:"insert-contact-without-external-id"`
+ InsertContactIfAbsent *sqlx.Stmt `query:"insert-contact-if-absent"`
+ GetContactByEmail *sqlx.Stmt `query:"get-contact-by-email"`
+ GetContactByEmailWithoutExtID *sqlx.Stmt `query:"get-contact-by-email-without-ext-id"`
+ IsEmailBlocked *sqlx.Stmt `query:"is-email-blocked"`
+ SetExternalUserID *sqlx.Stmt `query:"set-external-user-id"`
+ SetContactPhoneIfMissing *sqlx.Stmt `query:"set-contact-phone-if-missing"`
+ UpdateContactNameIfDefault *sqlx.Stmt `query:"update-contact-name-if-default"`
+ GetContactIDByChannelIdentity *sqlx.Stmt `query:"get-contact-id-by-channel-identity"`
+ GetChannelIdentitiesByContact *sqlx.Stmt `query:"get-channel-identities-by-contact"`
+ GetChannelIdentity *sqlx.Stmt `query:"get-channel-identity"`
+ InsertChannelIdentity *sqlx.Stmt `query:"insert-channel-identity"`
+ UpdateChannelIdentity *sqlx.Stmt `query:"update-channel-identity"`
+ UpsertContactWithChannelIdentity *sqlx.Stmt `query:"upsert-contact-with-channel-identity"`
+ DeleteOrphanedContact *sqlx.Stmt `query:"delete-orphaned-contact"`
+ InsertNote *sqlx.Stmt `query:"insert-note"`
+ InsertVisitor *sqlx.Stmt `query:"insert-visitor"`
+ GetVisitorByEmail *sqlx.Stmt `query:"get-visitor-by-email"`
+ UpgradeVisitorToContact *sqlx.Stmt `query:"upgrade-visitor-to-contact"`
+ ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
// API key queries
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
diff --git a/internal/whatsapp/client.go b/internal/whatsapp/client.go
new file mode 100644
index 000000000..3ef49b6f8
--- /dev/null
+++ b/internal/whatsapp/client.go
@@ -0,0 +1,411 @@
+package whatsapp
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/zerodha/logf"
+)
+
+const (
+ defaultGraphURL = "https://graph.facebook.com"
+ defaultTimeout = 30 * time.Second
+ // maxMediaDownloadBytes matches Meta's largest media cap (100MB documents).
+ maxMediaDownloadBytes = 100 * 1024 * 1024
+ maxTemplatePages = 100
+)
+
+var metaHostSuffixes = []string{"facebook.com", "fbcdn.net", "fbsbx.com", "whatsapp.net", "whatsapp.com"}
+
+type Client struct {
+ httpClient *http.Client
+ lo *logf.Logger
+ baseURL string
+ onAuthError func(acc Account)
+}
+
+func (c *Client) SetAuthErrorHook(fn func(acc Account)) { c.onAuthError = fn }
+
+func (c *Client) notifyAuthError(acc Account, err error) {
+ var me *MetaAPIError
+ if c.onAuthError != nil && errors.As(err, &me) && (me.StatusCode == http.StatusUnauthorized || me.Code == 190) {
+ c.onAuthError(acc)
+ }
+}
+
+func New(lo *logf.Logger) *Client {
+ return &Client{
+ httpClient: &http.Client{Timeout: defaultTimeout},
+ lo: lo,
+ baseURL: defaultGraphURL,
+ }
+}
+
+func (c *Client) SetBaseURL(u string) { c.baseURL = strings.TrimRight(u, "/") }
+
+func (c *Client) ValidateCredentials(ctx context.Context, acc Account) error {
+ endpoint := fmt.Sprintf("%s/%s/%s", c.baseURL, acc.Version(), acc.PhoneNumberID)
+ if _, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, acc); err != nil {
+ return err
+ }
+ return c.checkPhoneNumberInWABA(ctx, acc)
+}
+
+// checkPhoneNumberInWABA rejects a phone-number ID that belongs to a different WABA reachable with the same token.
+func (c *Client) checkPhoneNumberInWABA(ctx context.Context, acc Account) error {
+ endpoint := fmt.Sprintf("%s/%s/%s/phone_numbers?limit=100", c.baseURL, acc.Version(), acc.WABAID)
+ for page := 0; endpoint != "" && page < maxTemplatePages; page++ {
+ if page > 0 {
+ if err := c.checkAuthenticatedHost(endpoint); err != nil {
+ return err
+ }
+ }
+ body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, acc)
+ if err != nil {
+ return err
+ }
+ var resp phoneNumberListResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return fmt.Errorf("decoding phone number list: %w", err)
+ }
+ for _, pn := range resp.Data {
+ if pn.ID == acc.PhoneNumberID {
+ return nil
+ }
+ }
+ endpoint = resp.Paging.Next
+ }
+ return fmt.Errorf("phone number ID %s does not belong to WhatsApp business account %s", acc.PhoneNumberID, acc.WABAID)
+}
+
+func (c *Client) SendText(ctx context.Context, acc Account, toPhone, body, replyToID string) (string, error) {
+ payload := map[string]any{
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": toPhone,
+ "type": "text",
+ "text": map[string]any{"body": body, "preview_url": false},
+ }
+ if replyToID != "" {
+ payload["context"] = map[string]string{"message_id": replyToID}
+ }
+ return c.sendMessage(ctx, acc, payload)
+}
+
+// SendMedia sends a media message; mediaType is one of image, video, audio, document, sticker.
+func (c *Client) SendMedia(ctx context.Context, acc Account, toPhone, mediaType, mediaID, caption, filename, replyToID string) (string, error) {
+ media := map[string]any{"id": mediaID}
+ if caption != "" && (mediaType == "image" || mediaType == "video" || mediaType == "document") {
+ media["caption"] = caption
+ }
+ if filename != "" && mediaType == "document" {
+ media["filename"] = filename
+ }
+ payload := map[string]any{
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": toPhone,
+ "type": mediaType,
+ mediaType: media,
+ }
+ if replyToID != "" {
+ payload["context"] = map[string]string{"message_id": replyToID}
+ }
+ return c.sendMessage(ctx, acc, payload)
+}
+
+func (c *Client) SendTemplate(ctx context.Context, acc Account, toPhone, name, language string, components []map[string]any) (string, error) {
+ tmpl := map[string]any{
+ "name": name,
+ "language": map[string]string{"code": language},
+ }
+ if len(components) > 0 {
+ tmpl["components"] = components
+ }
+ payload := map[string]any{
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": toPhone,
+ "type": "template",
+ "template": tmpl,
+ }
+ return c.sendMessage(ctx, acc, payload)
+}
+
+// SubscribeWebhook subscribes the app to the WABA and points its webhook at callbackURL, which Meta verifies with a GET handshake so it must be publicly reachable.
+func (c *Client) SubscribeWebhook(ctx context.Context, acc Account, callbackURL, verifyToken string) error {
+ endpoint := fmt.Sprintf("%s/%s/%s/subscribed_apps", c.baseURL, acc.Version(), acc.WABAID)
+ if _, err := c.doRequest(ctx, http.MethodPost, endpoint, nil, acc); err != nil {
+ return fmt.Errorf("subscribing app to waba: %w", err)
+ }
+ payload := map[string]any{
+ "override_callback_uri": callbackURL,
+ "verify_token": verifyToken,
+ }
+ if _, err := c.doRequest(ctx, http.MethodPost, endpoint, payload, acc); err != nil {
+ return fmt.Errorf("overriding waba callback: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) MarkRead(ctx context.Context, acc Account, messageID string) error {
+ payload := map[string]any{
+ "messaging_product": "whatsapp",
+ "status": "read",
+ "message_id": messageID,
+ }
+ endpoint := fmt.Sprintf("%s/%s/%s/messages", c.baseURL, acc.Version(), acc.PhoneNumberID)
+ _, err := c.doRequest(ctx, http.MethodPost, endpoint, payload, acc)
+ return err
+}
+
+func (c *Client) GetMediaURL(ctx context.Context, acc Account, mediaID string) (MediaInfo, error) {
+ endpoint := fmt.Sprintf("%s/%s/%s", c.baseURL, acc.Version(), mediaID)
+ body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, acc)
+ if err != nil {
+ return MediaInfo{}, err
+ }
+ var info MediaInfo
+ if err := json.Unmarshal(body, &info); err != nil {
+ return MediaInfo{}, fmt.Errorf("decoding media info: %w", err)
+ }
+ return info, nil
+}
+
+func (c *Client) DownloadMedia(ctx context.Context, acc Account, mediaURL string) ([]byte, error) {
+ if err := c.checkAuthenticatedHost(mediaURL); err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, mediaURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("building media request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+acc.AccessToken)
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("downloading media: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
+ metaErr := parseMetaError(resp.StatusCode, respBody)
+ c.notifyAuthError(acc, metaErr)
+ return nil, metaErr
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxMediaDownloadBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("reading media body: %w", err)
+ }
+ if len(body) > maxMediaDownloadBytes {
+ return nil, fmt.Errorf("media exceeds %d bytes", maxMediaDownloadBytes)
+ }
+ return body, nil
+}
+
+func (c *Client) UploadMedia(ctx context.Context, acc Account, content []byte, contentType, filename string) (string, error) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ if err := mw.WriteField("messaging_product", "whatsapp"); err != nil {
+ return "", err
+ }
+ if err := mw.WriteField("type", contentType); err != nil {
+ return "", err
+ }
+ // Meta validates the file part's own Content-Type, which CreateFormFile hardcodes to octet-stream.
+ partHeader := make(textproto.MIMEHeader)
+ partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, filename))
+ partHeader.Set("Content-Type", contentType)
+ fw, err := mw.CreatePart(partHeader)
+ if err != nil {
+ return "", err
+ }
+ if _, err := fw.Write(content); err != nil {
+ return "", err
+ }
+ if err := mw.Close(); err != nil {
+ return "", err
+ }
+
+ endpoint := fmt.Sprintf("%s/%s/%s/media", c.baseURL, acc.Version(), acc.PhoneNumberID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+acc.AccessToken)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("uploading media: %w", err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ metaErr := parseMetaError(resp.StatusCode, respBody)
+ c.notifyAuthError(acc, metaErr)
+ return "", metaErr
+ }
+ var out UploadMediaResponse
+ if err := json.Unmarshal(respBody, &out); err != nil {
+ return "", fmt.Errorf("decoding upload response: %w", err)
+ }
+ return out.ID, nil
+}
+
+// FetchTemplates lists templates from a WABA, walking pagination until exhausted.
+func (c *Client) FetchTemplates(ctx context.Context, acc Account) ([]MetaTemplate, error) {
+ endpoint := fmt.Sprintf("%s/%s/%s/message_templates?limit=100", c.baseURL, acc.Version(), acc.WABAID)
+ var out []MetaTemplate
+ for page := 0; endpoint != "" && page < maxTemplatePages; page++ {
+ if page > 0 {
+ if err := c.checkAuthenticatedHost(endpoint); err != nil {
+ return nil, err
+ }
+ }
+ body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, acc)
+ if err != nil {
+ return nil, err
+ }
+ var page templateListResponse
+ if err := json.Unmarshal(body, &page); err != nil {
+ return nil, fmt.Errorf("decoding template list: %w", err)
+ }
+ out = append(out, page.Data...)
+ endpoint = page.Paging.Next
+ }
+ return out, nil
+}
+
+func (c *Client) SubmitTemplate(ctx context.Context, acc Account, t TemplateSubmission) (string, error) {
+ endpoint := fmt.Sprintf("%s/%s/%s/message_templates", c.baseURL, acc.Version(), acc.WABAID)
+ body, err := c.doRequest(ctx, http.MethodPost, endpoint, t, acc)
+ if err != nil {
+ return "", err
+ }
+ var resp struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Category string `json:"category"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", fmt.Errorf("decoding submit response: %w", err)
+ }
+ return resp.ID, nil
+}
+
+// DeleteTemplate without a Meta template ID deletes every language variant sharing the name.
+func (c *Client) DeleteTemplate(ctx context.Context, acc Account, name, metaTemplateID string) error {
+ endpoint := fmt.Sprintf("%s/%s/%s/message_templates?name=%s", c.baseURL, acc.Version(), acc.WABAID, url.QueryEscape(name))
+ if metaTemplateID != "" {
+ endpoint += "&hsm_id=" + url.QueryEscape(metaTemplateID)
+ }
+ _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, acc)
+ return err
+}
+
+// EditTemplate updates an existing template's content by Meta template ID; Meta resets it to pending review. Name and language cannot be changed this way.
+func (c *Client) EditTemplate(ctx context.Context, acc Account, metaTemplateID string, t TemplateEdit) error {
+ endpoint := fmt.Sprintf("%s/%s/%s", c.baseURL, acc.Version(), metaTemplateID)
+ _, err := c.doRequest(ctx, http.MethodPost, endpoint, t, acc)
+ return err
+}
+
+func (c *Client) sendMessage(ctx context.Context, acc Account, payload any) (string, error) {
+ endpoint := fmt.Sprintf("%s/%s/%s/messages", c.baseURL, acc.Version(), acc.PhoneNumberID)
+ body, err := c.doRequest(ctx, http.MethodPost, endpoint, payload, acc)
+ if err != nil {
+ return "", err
+ }
+ var resp SendResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", fmt.Errorf("decoding send response: %w", err)
+ }
+ if len(resp.Messages) == 0 {
+ return "", fmt.Errorf("no message id in send response")
+ }
+ return resp.Messages[0].ID, nil
+}
+
+func (c *Client) doRequest(ctx context.Context, method, endpoint string, body any, acc Account) ([]byte, error) {
+ var reader io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("encoding request body: %w", err)
+ }
+ reader = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
+ if err != nil {
+ return nil, fmt.Errorf("building request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+acc.AccessToken)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("calling meta api: %w", err)
+ }
+ defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return nil, fmt.Errorf("reading meta response: %w", readErr)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ metaErr := parseMetaError(resp.StatusCode, respBody)
+ c.notifyAuthError(acc, metaErr)
+ return respBody, metaErr
+ }
+ return respBody, nil
+}
+
+func parseMetaError(statusCode int, respBody []byte) error {
+ var env metaErrorEnvelope
+ if err := json.Unmarshal(respBody, &env); err != nil || env.Error.Message == "" {
+ return &MetaAPIError{
+ StatusCode: statusCode,
+ Message: fmt.Sprintf("meta api returned status %d: %s", statusCode, string(respBody)),
+ }
+ }
+ return &MetaAPIError{
+ StatusCode: statusCode,
+ Message: env.Error.Message,
+ Type: env.Error.Type,
+ Code: env.Error.Code,
+ Subcode: env.Error.ErrorSubcode,
+ UserMsg: env.Error.ErrorUserMsg,
+ FBTraceID: env.Error.FBTraceID,
+ }
+}
+
+// checkAuthenticatedHost guards URLs read out of Meta response bodies before the access token is attached.
+func (c *Client) checkAuthenticatedHost(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return fmt.Errorf("refusing to call malformed url %q", raw)
+ }
+ host := strings.ToLower(u.Hostname())
+ // The configured API host is trusted on its own scheme, everything else must be https.
+ if base, err := url.Parse(c.baseURL); err == nil && strings.EqualFold(base.Hostname(), host) && base.Scheme == u.Scheme {
+ return nil
+ }
+ if u.Scheme != "https" {
+ return fmt.Errorf("refusing to call non-https url %q", raw)
+ }
+ for _, suffix := range metaHostSuffixes {
+ if host == suffix || strings.HasSuffix(host, "."+suffix) {
+ return nil
+ }
+ }
+ return fmt.Errorf("refusing to send credentials to unexpected host %q", host)
+}
diff --git a/internal/whatsapp/client_test.go b/internal/whatsapp/client_test.go
new file mode 100644
index 000000000..6d601f906
--- /dev/null
+++ b/internal/whatsapp/client_test.go
@@ -0,0 +1,1001 @@
+package whatsapp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/zerodha/logf"
+)
+
+const testToken = "TOKEN123"
+
+func TestAccountVersion(t *testing.T) {
+ if got := (Account{}).Version(); got != DefaultAPIVersion {
+ t.Fatalf("expected the default version, got %q", got)
+ }
+ if got := (Account{APIVersion: "v21.0"}).Version(); got != "v21.0" {
+ t.Fatalf("expected v21.0, got %q", got)
+ }
+}
+
+func TestMetaAPIErrorMessage(t *testing.T) {
+ if got := (&MetaAPIError{Message: "internal"}).Error(); got != "internal" {
+ t.Fatalf("expected the message, got %q", got)
+ }
+ // The user message is what an agent should see when Meta provides one.
+ if got := (&MetaAPIError{Message: "internal", UserMsg: "for the agent"}).Error(); got != "for the agent" {
+ t.Fatalf("expected the user message, got %q", got)
+ }
+}
+
+func TestSetBaseURLTrimsTrailingSlash(t *testing.T) {
+ c := New(testLogger())
+ c.SetBaseURL("https://graph.example.test/")
+ if c.baseURL != "https://graph.example.test" {
+ t.Fatalf("unexpected base url %q", c.baseURL)
+ }
+}
+
+func TestValidateCredentials(t *testing.T) {
+ var paths []string
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ if strings.HasSuffix(r.URL.Path, "/phone_numbers") {
+ writeJSON(w, 200, map[string]any{"data": []map[string]string{{"id": "PN1"}}})
+ return
+ }
+ writeJSON(w, 200, map[string]any{"id": "PN1"})
+ })
+ if err := c.ValidateCredentials(context.Background(), testAccount()); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // A token scoped to only the number must not pass the WABA check.
+ want := []string{"/v25.0/PN1", "/v25.0/WABA1/phone_numbers"}
+ if strings.Join(paths, ",") != strings.Join(want, ",") {
+ t.Fatalf("expected %v, got %v", want, paths)
+ }
+}
+
+// A phone number ID from a different WABA reachable with the same token must be rejected.
+func TestValidateCredentialsRejectsForeignPhoneNumber(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/phone_numbers") {
+ writeJSON(w, 200, map[string]any{"data": []map[string]string{{"id": "OTHER"}}})
+ return
+ }
+ writeJSON(w, 200, map[string]any{"id": "PN1"})
+ })
+ err := c.ValidateCredentials(context.Background(), testAccount())
+ if err == nil || !strings.Contains(err.Error(), "does not belong") {
+ t.Fatalf("expected a membership error, got %v", err)
+ }
+}
+
+func TestValidateCredentialsFailsOnPhoneNumber(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "bad id", 803)
+ })
+ if err := c.ValidateCredentials(context.Background(), testAccount()); err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+func TestValidateCredentialsFailsOnWABA(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/phone_numbers") {
+ metaError(w, 400, "no waba", 803)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"id": "PN1"})
+ })
+ if err := c.ValidateCredentials(context.Background(), testAccount()); err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+func TestSendText(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/v25.0/PN1/messages" {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer "+testToken {
+ t.Errorf("unexpected auth header %q", got)
+ }
+ if got := r.Header.Get("Content-Type"); got != "application/json" {
+ t.Errorf("unexpected content type %q", got)
+ }
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.1"))
+ })
+
+ id, err := c.SendText(context.Background(), testAccount(), "919876543210", "hello", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id != "wamid.1" {
+ t.Fatalf("unexpected message id %q", id)
+ }
+ text := body["text"].(map[string]any)
+ if body["type"] != "text" || text["body"] != "hello" || text["preview_url"] != false {
+ t.Fatalf("unexpected payload: %v", body)
+ }
+ if _, ok := body["context"]; ok {
+ t.Fatal("a reply context must be omitted when there is nothing to reply to")
+ }
+}
+
+func TestSendTextAsReply(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.2"))
+ })
+ if _, err := c.SendText(context.Background(), testAccount(), "919876543210", "hello", "wamid.orig"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ ctx := body["context"].(map[string]any)
+ if ctx["message_id"] != "wamid.orig" {
+ t.Fatalf("unexpected context: %v", body["context"])
+ }
+}
+
+func TestSendMedia(t *testing.T) {
+ tests := []struct {
+ name string
+ mediaType string
+ caption string
+ filename string
+ wantCaption bool
+ wantName bool
+ }{
+ {"image keeps the caption", "image", "look", "photo.jpg", true, false},
+ {"video keeps the caption", "video", "look", "clip.mp4", true, false},
+ {"document keeps both", "document", "invoice", "invoice.pdf", true, true},
+ {"audio takes neither", "audio", "look", "note.ogg", false, false},
+ {"empty caption is omitted", "image", "", "photo.jpg", false, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.3"))
+ })
+ id, err := c.SendMedia(context.Background(), testAccount(), "919876543210", tc.mediaType, "MEDIA1", tc.caption, tc.filename, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id != "wamid.3" {
+ t.Fatalf("unexpected id %q", id)
+ }
+ media := body[tc.mediaType].(map[string]any)
+ if media["id"] != "MEDIA1" {
+ t.Fatalf("unexpected media id: %v", media)
+ }
+ if _, ok := media["caption"]; ok != tc.wantCaption {
+ t.Fatalf("caption present=%v, want %v", ok, tc.wantCaption)
+ }
+ if _, ok := media["filename"]; ok != tc.wantName {
+ t.Fatalf("filename present=%v, want %v", ok, tc.wantName)
+ }
+ })
+ }
+}
+
+func TestSendMediaAsReply(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.4"))
+ })
+ if _, err := c.SendMedia(context.Background(), testAccount(), "919876543210", "image", "MEDIA1", "", "", "wamid.orig"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if body["context"] == nil {
+ t.Fatal("expected a reply context")
+ }
+}
+
+func TestSendTemplate(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.5"))
+ })
+ components := []map[string]any{{"type": "body", "parameters": []map[string]any{{"type": "text", "text": "Ravi"}}}}
+ if _, err := c.SendTemplate(context.Background(), testAccount(), "919876543210", "order_update", "en_US", components); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ tmpl := body["template"].(map[string]any)
+ if tmpl["name"] != "order_update" {
+ t.Fatalf("unexpected template: %v", tmpl)
+ }
+ if tmpl["language"].(map[string]any)["code"] != "en_US" {
+ t.Fatalf("unexpected language: %v", tmpl["language"])
+ }
+ if len(tmpl["components"].([]any)) != 1 {
+ t.Fatalf("unexpected components: %v", tmpl["components"])
+ }
+}
+
+func TestSendTemplateWithoutComponents(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, sendResponse("wamid.6"))
+ })
+ if _, err := c.SendTemplate(context.Background(), testAccount(), "919876543210", "hello_world", "en_US", nil); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, ok := body["template"].(map[string]any)["components"]; ok {
+ t.Fatal("expected no components key")
+ }
+}
+
+func TestSendMessageWithoutMessageID(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, 200, map[string]any{"messaging_product": "whatsapp", "messages": []any{}})
+ })
+ if _, err := c.SendText(context.Background(), testAccount(), "919876543210", "hello", ""); err == nil {
+ t.Fatal("expected an error when Meta returns no message id")
+ }
+}
+
+func TestSendMessageWithUndecodableResponse(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte("not json"))
+ })
+ if _, err := c.SendText(context.Background(), testAccount(), "919876543210", "hello", ""); err == nil {
+ t.Fatal("expected a decode error")
+ }
+}
+
+func TestSubscribeWebhook(t *testing.T) {
+ var bodies []string
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v25.0/WABA1/subscribed_apps" {
+ t.Errorf("unexpected path %s", r.URL.Path)
+ }
+ raw, _ := io.ReadAll(r.Body)
+ bodies = append(bodies, string(raw))
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ if err := c.SubscribeWebhook(context.Background(), testAccount(), "https://desk.example.test/webhooks/whatsapp/1", "verify-me"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(bodies) != 2 {
+ t.Fatalf("expected a subscribe then an override, got %d calls", len(bodies))
+ }
+ if bodies[0] != "" {
+ t.Fatalf("expected the subscribe call to carry no body, got %q", bodies[0])
+ }
+ if !strings.Contains(bodies[1], "override_callback_uri") || !strings.Contains(bodies[1], "verify-me") {
+ t.Fatalf("unexpected override body %q", bodies[1])
+ }
+}
+
+func TestSubscribeWebhookFailures(t *testing.T) {
+ t.Run("subscribe fails", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "cannot subscribe", 100)
+ })
+ err := c.SubscribeWebhook(context.Background(), testAccount(), "https://desk.example.test/x", "v")
+ if err == nil || !strings.Contains(err.Error(), "subscribing app to waba") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("override fails", func(t *testing.T) {
+ calls := 0
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ if calls == 1 {
+ writeJSON(w, 200, map[string]bool{"success": true})
+ return
+ }
+ metaError(w, 400, "cannot override", 100)
+ })
+ err := c.SubscribeWebhook(context.Background(), testAccount(), "https://desk.example.test/x", "v")
+ if err == nil || !strings.Contains(err.Error(), "overriding waba callback") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+func TestMarkRead(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ decode(t, r, &body)
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ if err := c.MarkRead(context.Background(), testAccount(), "wamid.9"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if body["status"] != "read" || body["message_id"] != "wamid.9" {
+ t.Fatalf("unexpected payload: %v", body)
+ }
+}
+
+func TestGetMediaURL(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, 200, map[string]any{"url": "https://mmg.whatsapp.net/x", "mime_type": "image/png", "file_size": 12, "id": "MEDIA1"})
+ })
+ _ = srv
+ info, err := c.GetMediaURL(context.Background(), testAccount(), "MEDIA1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if info.URL != "https://mmg.whatsapp.net/x" || info.MimeType != "image/png" || info.FileSize != 12 {
+ t.Fatalf("unexpected media info: %+v", info)
+ }
+}
+
+func TestGetMediaURLErrors(t *testing.T) {
+ t.Run("meta error", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 404, "gone", 100)
+ })
+ if _, err := c.GetMediaURL(context.Background(), testAccount(), "MEDIA1"); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("undecodable body", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte("not json"))
+ })
+ if _, err := c.GetMediaURL(context.Background(), testAccount(), "MEDIA1"); err == nil {
+ t.Fatal("expected a decode error")
+ }
+ })
+}
+
+func TestDownloadMedia(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer "+testToken {
+ t.Errorf("media download must carry the token, got %q", got)
+ }
+ w.Write([]byte("filebytes"))
+ })
+ body, err := c.DownloadMedia(context.Background(), testAccount(), srv.URL+"/media/MEDIA1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if string(body) != "filebytes" {
+ t.Fatalf("unexpected body %q", body)
+ }
+}
+
+func TestDownloadMediaErrors(t *testing.T) {
+ t.Run("meta error", func(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 404, "expired", 100)
+ })
+ if _, err := c.DownloadMedia(context.Background(), testAccount(), srv.URL+"/media/x"); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("unexpected host", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ _, err := c.DownloadMedia(context.Background(), testAccount(), "https://evil.example.com/media/x")
+ if err == nil || !strings.Contains(err.Error(), "unexpected host") {
+ t.Fatalf("expected a host check failure, got %v", err)
+ }
+ })
+
+ t.Run("unreachable host", func(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ url := srv.URL + "/media/x"
+ srv.Close()
+ if _, err := c.DownloadMedia(context.Background(), testAccount(), url); err == nil {
+ t.Fatal("expected a transport error")
+ }
+ })
+
+ t.Run("cancelled context", func(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := c.DownloadMedia(ctx, testAccount(), srv.URL+"/media/x"); err == nil {
+ t.Fatal("expected the cancelled context to fail the request")
+ }
+ })
+}
+
+// A body over Meta's 100MB cap must be refused rather than buffered whole.
+func TestDownloadMediaSizeCap(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ chunk := make([]byte, 1<<20)
+ for range (maxMediaDownloadBytes / len(chunk)) + 1 {
+ if _, err := w.Write(chunk); err != nil {
+ return
+ }
+ }
+ })
+ _, err := c.DownloadMedia(context.Background(), testAccount(), srv.URL+"/media/big")
+ if err == nil || !strings.Contains(err.Error(), "exceeds") {
+ t.Fatalf("expected a size error, got %v", err)
+ }
+}
+
+func TestUploadMedia(t *testing.T) {
+ var (
+ gotType string
+ gotFilename string
+ gotPartType string
+ gotContent string
+ )
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v25.0/PN1/media" {
+ t.Errorf("unexpected path %s", r.URL.Path)
+ }
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Fatalf("parse multipart: %v", err)
+ }
+ if r.MultipartForm.Value["messaging_product"][0] != "whatsapp" {
+ t.Error("messaging_product must be whatsapp")
+ }
+ gotType = r.MultipartForm.Value["type"][0]
+ fh := r.MultipartForm.File["file"][0]
+ gotFilename = fh.Filename
+ gotPartType = fh.Header.Get("Content-Type")
+ f, _ := fh.Open()
+ raw, _ := io.ReadAll(f)
+ gotContent = string(raw)
+ writeJSON(w, 200, map[string]string{"id": "MEDIAUP1"})
+ })
+
+ id, err := c.UploadMedia(context.Background(), testAccount(), []byte("filebytes"), "image/png", "photo.png")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id != "MEDIAUP1" {
+ t.Fatalf("unexpected media id %q", id)
+ }
+ if gotType != "image/png" || gotFilename != "photo.png" || gotContent != "filebytes" {
+ t.Fatalf("unexpected upload: type=%q name=%q content=%q", gotType, gotFilename, gotContent)
+ }
+ // Meta validates the file part's own content type, not just the form field.
+ if gotPartType != "image/png" {
+ t.Fatalf("expected the part content type to be image/png, got %q", gotPartType)
+ }
+}
+
+func TestUploadMediaErrors(t *testing.T) {
+ t.Run("meta error", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "too big", 100)
+ })
+ if _, err := c.UploadMedia(context.Background(), testAccount(), []byte("x"), "image/png", "a.png"); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("undecodable body", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte("not json"))
+ })
+ if _, err := c.UploadMedia(context.Background(), testAccount(), []byte("x"), "image/png", "a.png"); err == nil {
+ t.Fatal("expected a decode error")
+ }
+ })
+
+ t.Run("unreachable host", func(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ srv.Close()
+ if _, err := c.UploadMedia(context.Background(), testAccount(), []byte("x"), "image/png", "a.png"); err == nil {
+ t.Fatal("expected a transport error")
+ }
+ })
+
+ t.Run("cancelled context", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := c.UploadMedia(ctx, testAccount(), []byte("x"), "image/png", "a.png"); err == nil {
+ t.Fatal("expected the cancelled context to fail the request")
+ }
+ })
+}
+
+func TestFetchTemplatesPaginates(t *testing.T) {
+ var srv *httptest.Server
+ page := 0
+ c, s := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ page++
+ if page == 1 {
+ writeJSON(w, 200, map[string]any{
+ "data": []map[string]any{{"id": "1", "name": "first"}},
+ "paging": map[string]any{"next": srv.URL + "/v25.0/WABA1/message_templates?after=cursor"},
+ })
+ return
+ }
+ writeJSON(w, 200, map[string]any{"data": []map[string]any{{"id": "2", "name": "second"}}})
+ })
+ srv = s
+
+ out, err := c.FetchTemplates(context.Background(), testAccount())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(out) != 2 || out[0].Name != "first" || out[1].Name != "second" {
+ t.Fatalf("unexpected templates: %+v", out)
+ }
+}
+
+// A next link pointing somewhere other than Meta would leak the access token.
+func TestFetchTemplatesRejectsForeignNextLink(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, 200, map[string]any{
+ "data": []map[string]any{{"id": "1", "name": "first"}},
+ "paging": map[string]any{"next": "https://evil.example.com/steal"},
+ })
+ })
+ _, err := c.FetchTemplates(context.Background(), testAccount())
+ if err == nil || !strings.Contains(err.Error(), "unexpected host") {
+ t.Fatalf("expected a host check failure, got %v", err)
+ }
+}
+
+func TestFetchTemplatesStopsAtPageCap(t *testing.T) {
+ var srv *httptest.Server
+ pages := 0
+ c, s := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ pages++
+ writeJSON(w, 200, map[string]any{
+ "data": []map[string]any{{"id": fmt.Sprint(pages), "name": "t"}},
+ "paging": map[string]any{"next": srv.URL + "/v25.0/WABA1/message_templates?after=loop"},
+ })
+ })
+ srv = s
+
+ out, err := c.FetchTemplates(context.Background(), testAccount())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if pages != maxTemplatePages || len(out) != maxTemplatePages {
+ t.Fatalf("expected to stop at %d pages, made %d and got %d templates", maxTemplatePages, pages, len(out))
+ }
+}
+
+func TestFetchTemplatesErrors(t *testing.T) {
+ t.Run("meta error", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 401, "bad token", 190)
+ })
+ if _, err := c.FetchTemplates(context.Background(), testAccount()); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("undecodable body", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte("not json"))
+ })
+ if _, err := c.FetchTemplates(context.Background(), testAccount()); err == nil {
+ t.Fatal("expected a decode error")
+ }
+ })
+}
+
+func TestSubmitTemplate(t *testing.T) {
+ var body map[string]any
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/v25.0/WABA1/message_templates" {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ decode(t, r, &body)
+ writeJSON(w, 200, map[string]any{"id": "999", "status": "PENDING", "category": "UTILITY"})
+ })
+
+ id, err := c.SubmitTemplate(context.Background(), testAccount(), TemplateSubmission{
+ Name: "order_update",
+ Language: "en_US",
+ Category: "UTILITY",
+ Components: []TemplateComponent{
+ {Type: "BODY", Text: "Hi {{1}}", Example: map[string]any{"body_text": [][]string{{"Ravi"}}}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id != "999" {
+ t.Fatalf("unexpected template id %q", id)
+ }
+ if body["name"] != "order_update" || body["language"] != "en_US" {
+ t.Fatalf("unexpected submission: %v", body)
+ }
+}
+
+func TestSubmitTemplateErrors(t *testing.T) {
+ t.Run("meta error", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "policy violation", 100)
+ })
+ if _, err := c.SubmitTemplate(context.Background(), testAccount(), TemplateSubmission{Name: "x"}); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("undecodable body", func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte("not json"))
+ })
+ if _, err := c.SubmitTemplate(context.Background(), testAccount(), TemplateSubmission{Name: "x"}); err == nil {
+ t.Fatal("expected a decode error")
+ }
+ })
+}
+
+func TestDeleteTemplateEscapesName(t *testing.T) {
+ var gotQuery string
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ t.Errorf("unexpected method %s", r.Method)
+ }
+ gotQuery = r.URL.RawQuery
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ if err := c.DeleteTemplate(context.Background(), testAccount(), "name with space&more", ""); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if gotQuery != "name=name+with+space%26more" {
+ t.Fatalf("unexpected query %q", gotQuery)
+ }
+}
+
+func TestDeleteTemplateByIDTargetsOneVariant(t *testing.T) {
+ var gotQuery string
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ gotQuery = r.URL.RawQuery
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ if err := c.DeleteTemplate(context.Background(), testAccount(), "promo", "12345"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if gotQuery != "name=promo&hsm_id=12345" {
+ t.Fatalf("unexpected query %q", gotQuery)
+ }
+}
+
+func TestDeleteTemplateError(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "not found", 100)
+ })
+ if err := c.DeleteTemplate(context.Background(), testAccount(), "x", ""); err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+func TestEditTemplate(t *testing.T) {
+ var (
+ gotPath string
+ body map[string]any
+ )
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ decode(t, r, &body)
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ err := c.EditTemplate(context.Background(), testAccount(), "TID9", TemplateEdit{
+ Category: "UTILITY",
+ Components: []TemplateComponent{{Type: "BODY", Text: "new copy"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if gotPath != "/v25.0/TID9" {
+ t.Fatalf("unexpected path %q", gotPath)
+ }
+ if _, ok := body["name"]; ok {
+ t.Fatal("an edit must not send the template name")
+ }
+}
+
+func TestEditTemplateError(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, 400, "cannot edit while pending", 100)
+ })
+ err := c.EditTemplate(context.Background(), testAccount(), "TID9", TemplateEdit{})
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+func TestDoRequestTransportFailure(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ srv.Close()
+ err := c.MarkRead(context.Background(), testAccount(), "wamid.1")
+ if err == nil || !strings.Contains(err.Error(), "calling meta api") {
+ t.Fatalf("expected a transport error, got %v", err)
+ }
+}
+
+func TestDoRequestUnbuildableRequest(t *testing.T) {
+ c := New(testLogger())
+ c.SetBaseURL("http://\x7f invalid")
+ if err := c.MarkRead(context.Background(), testAccount(), "wamid.1"); err == nil {
+ t.Fatal("expected a request build error")
+ }
+}
+
+func TestDoRequestUnmarshalableBody(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {})
+ // Channels cannot be marshalled, so the encode step must fail before any call is made.
+ _, err := c.SubmitTemplate(context.Background(), testAccount(), TemplateSubmission{
+ Components: []TemplateComponent{{Type: "BODY", Example: map[string]any{"bad": make(chan int)}}},
+ })
+ if err == nil || !strings.Contains(err.Error(), "encoding request body") {
+ t.Fatalf("expected an encode error, got %v", err)
+ }
+}
+
+func TestParseMetaError(t *testing.T) {
+ t.Run("structured error", func(t *testing.T) {
+ raw := []byte(`{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":2494010,"error_user_msg":"Template does not exist","fbtrace_id":"ABC"}}`)
+ err := parseMetaError(400, raw)
+ me, ok := err.(*MetaAPIError)
+ if !ok {
+ t.Fatalf("expected a MetaAPIError, got %T", err)
+ }
+ if me.StatusCode != 400 || me.Code != 100 || me.Subcode != 2494010 || me.Type != "OAuthException" || me.FBTraceID != "ABC" {
+ t.Fatalf("unexpected error: %+v", me)
+ }
+ if me.Error() != "Template does not exist" {
+ t.Fatalf("expected the user message, got %q", me.Error())
+ }
+ })
+
+ t.Run("unstructured body", func(t *testing.T) {
+ err := parseMetaError(502, []byte("bad gateway"))
+ me := err.(*MetaAPIError)
+ if me.StatusCode != 502 || !strings.Contains(me.Message, "bad gateway") {
+ t.Fatalf("unexpected error: %+v", me)
+ }
+ })
+
+ t.Run("json without a message", func(t *testing.T) {
+ err := parseMetaError(500, []byte(`{"error":{}}`))
+ me := err.(*MetaAPIError)
+ if !strings.Contains(me.Message, "status 500") {
+ t.Fatalf("unexpected error: %+v", me)
+ }
+ })
+}
+
+func TestCheckAuthenticatedHost(t *testing.T) {
+ c := New(testLogger())
+ c.SetBaseURL("https://graph.facebook.com")
+
+ allowed := []string{
+ "https://graph.facebook.com/v25.0/x",
+ "https://lookaside.fbsbx.com/media",
+ "https://scontent.xx.fbcdn.net/file",
+ "https://mmg.whatsapp.net/file",
+ "https://media.whatsapp.com/file",
+ "https://FACEBOOK.COM/upper",
+ }
+ for _, u := range allowed {
+ if err := c.checkAuthenticatedHost(u); err != nil {
+ t.Errorf("%s should be allowed: %v", u, err)
+ }
+ }
+
+ rejected := []string{
+ "http://graph.facebook.com/v25.0/x",
+ "https://evil.com/x",
+ "https://notfacebook.com/x",
+ "https://facebook.com.evil.com/x",
+ "://broken",
+ "",
+ "/relative/path",
+ }
+ for _, u := range rejected {
+ if err := c.checkAuthenticatedHost(u); err == nil {
+ t.Errorf("%s should be rejected", u)
+ }
+ }
+}
+
+// A stand-in Graph API (tests, on-prem gateway) is trusted on its own scheme, but only for its host.
+func TestCheckAuthenticatedHostWithPlainHTTPBaseURL(t *testing.T) {
+ c := New(testLogger())
+ c.SetBaseURL("http://127.0.0.1:9099")
+
+ if err := c.checkAuthenticatedHost("http://127.0.0.1:9099/media/abc"); err != nil {
+ t.Fatalf("the configured host must be allowed: %v", err)
+ }
+ if err := c.checkAuthenticatedHost("http://evil.example.com/media/abc"); err == nil {
+ t.Fatal("another plain-http host must still be refused")
+ }
+ // Meta's own CDNs stay allowed, and only over https.
+ if err := c.checkAuthenticatedHost("https://mmg.whatsapp.net/x"); err != nil {
+ t.Fatalf("meta cdn must be allowed: %v", err)
+ }
+ if err := c.checkAuthenticatedHost("http://mmg.whatsapp.net/x"); err == nil {
+ t.Fatal("plain http to a meta cdn must be refused")
+ }
+}
+
+// The hook lets the app flag an inbox whose token Meta no longer accepts.
+func TestAuthErrorHook(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ code int
+ wantFire bool
+ }{
+ {"401 fires", http.StatusUnauthorized, 0, true},
+ {"code 190 fires", http.StatusBadRequest, 190, true},
+ {"other errors do not", http.StatusBadRequest, 100, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, tc.status, "nope", tc.code)
+ })
+ var fired []Account
+ c.SetAuthErrorHook(func(acc Account) { fired = append(fired, acc) })
+ c.SendText(context.Background(), testAccount(), "919876543210", "hi", "")
+ if got := len(fired) > 0; got != tc.wantFire {
+ t.Fatalf("hook fired=%v, want %v", got, tc.wantFire)
+ }
+ if tc.wantFire && fired[0].PhoneNumberID != "PN1" {
+ t.Fatalf("hook got the wrong account: %+v", fired[0])
+ }
+ })
+ }
+}
+
+func TestAuthErrorHookOnMediaDownload(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, http.StatusUnauthorized, "expired", 190)
+ })
+ fired := 0
+ c.SetAuthErrorHook(func(acc Account) { fired++ })
+ c.DownloadMedia(context.Background(), testAccount(), srv.URL+"/media/x")
+ if fired != 1 {
+ t.Fatalf("expected the hook to fire once, got %d", fired)
+ }
+}
+
+func TestAuthErrorHookOnUpload(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, http.StatusUnauthorized, "expired", 190)
+ })
+ fired := 0
+ c.SetAuthErrorHook(func(acc Account) { fired++ })
+ c.UploadMedia(context.Background(), testAccount(), []byte("x"), "image/png", "a.png")
+ if fired != 1 {
+ t.Fatalf("expected the hook to fire once, got %d", fired)
+ }
+}
+
+func TestNoAuthErrorHookIsSafe(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ metaError(w, http.StatusUnauthorized, "expired", 190)
+ })
+ if _, err := c.SendText(context.Background(), testAccount(), "919876543210", "hi", ""); err == nil {
+ t.Fatal("expected an error")
+ }
+}
+
+func TestNewClientDefaults(t *testing.T) {
+ c := New(testLogger())
+ if c.baseURL != defaultGraphURL {
+ t.Fatalf("unexpected base url %q", c.baseURL)
+ }
+ if c.httpClient.Timeout != defaultTimeout {
+ t.Fatalf("unexpected timeout %s", c.httpClient.Timeout)
+ }
+ if c.lo == nil {
+ t.Fatal("expected a logger")
+ }
+}
+
+func TestRequestHonoursContextTimeout(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(200 * time.Millisecond)
+ writeJSON(w, 200, map[string]bool{"success": true})
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if err := c.MarkRead(ctx, testAccount(), "wamid.1"); err == nil {
+ t.Fatal("expected the request to time out")
+ }
+}
+
+// A truncated response body must surface as an error, not as a silently short file.
+func TestDownloadMediaTruncatedBody(t *testing.T) {
+ c, srv := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", "100")
+ w.WriteHeader(200)
+ w.Write([]byte("short"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ panic(http.ErrAbortHandler)
+ })
+ if _, err := c.DownloadMedia(context.Background(), testAccount(), srv.URL+"/media/x"); err == nil {
+ t.Fatal("expected a read error")
+ }
+}
+
+func TestDoRequestTruncatedBody(t *testing.T) {
+ c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", "100")
+ w.WriteHeader(200)
+ w.Write([]byte("short"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ panic(http.ErrAbortHandler)
+ })
+ err := c.MarkRead(context.Background(), testAccount(), "wamid.1")
+ if err == nil || !strings.Contains(err.Error(), "reading meta response") {
+ t.Fatalf("expected a read error, got %v", err)
+ }
+}
+
+func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
+ t.Helper()
+ srv := httptest.NewTLSServer(handler)
+ t.Cleanup(srv.Close)
+ c := New(testLogger())
+ c.SetBaseURL(srv.URL)
+ c.httpClient = srv.Client()
+ c.httpClient.Timeout = 5 * time.Second
+ return c, srv
+}
+
+func testAccount() Account {
+ return Account{PhoneNumberID: "PN1", WABAID: "WABA1", AccessToken: testToken, AppSecret: "SECRET"}
+}
+
+func testLogger() *logf.Logger {
+ l := logf.New(logf.Opts{Level: logf.FatalLevel})
+ return &l
+}
+
+func writeJSON(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ json.NewEncoder(w).Encode(v)
+}
+
+func metaError(w http.ResponseWriter, code int, msg string, errCode int) {
+ writeJSON(w, code, map[string]any{"error": map[string]any{"message": msg, "code": errCode, "type": "OAuthException"}})
+}
+
+func sendResponse(id string) map[string]any {
+ return map[string]any{
+ "messaging_product": "whatsapp",
+ "contacts": []map[string]string{{"input": "919876543210", "wa_id": "919876543210"}},
+ "messages": []map[string]string{{"id": id, "message_status": "accepted"}},
+ }
+}
+
+func decode(t *testing.T, r *http.Request, out any) {
+ t.Helper()
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ t.Fatalf("unmarshal body %q: %v", raw, err)
+ }
+}
diff --git a/internal/whatsapp/components.go b/internal/whatsapp/components.go
new file mode 100644
index 000000000..0da2df618
--- /dev/null
+++ b/internal/whatsapp/components.go
@@ -0,0 +1,140 @@
+package whatsapp
+
+import (
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// placeholderPattern matches {{1}} / {{name}} placeholders inside template text.
+var placeholderPattern = regexp.MustCompile(`\{\{([A-Za-z0-9_]+)\}\}`)
+
+// TemplateSendParts is the runtime context for a template send; Params is keyed by placeholder name, with button_url_ reserved for URL button parameters.
+type TemplateSendParts struct {
+ HeaderType string
+ HeaderContent string
+ BodyContent string
+ Buttons []TemplateButton
+ Params map[string]string
+}
+
+// BuildSendComponents returns the components array for a template send; components without parameters are omitted so Meta uses the approved text.
+func BuildSendComponents(p TemplateSendParts) []map[string]any {
+ var out []map[string]any
+
+ if h := buildHeaderComponent(p); h != nil {
+ out = append(out, h)
+ }
+
+ if body := buildBodyComponent(p.BodyContent, p.Params); body != nil {
+ out = append(out, body)
+ }
+
+ for i, b := range p.Buttons {
+ if c := buildButtonComponent(i, b, p.Params); c != nil {
+ out = append(out, c)
+ }
+ }
+
+ return out
+}
+
+// OrderedPlaceholders returns the distinct {{...}} names in text; all-numeric sets sort ascending to match Meta's positional mapping.
+func OrderedPlaceholders(text string) []string {
+ if text == "" {
+ return nil
+ }
+ matches := placeholderPattern.FindAllStringSubmatch(text, -1)
+ if len(matches) == 0 {
+ return nil
+ }
+ seen := make(map[string]bool, len(matches))
+ keys := make([]string, 0, len(matches))
+ allNumeric := true
+ for _, m := range matches {
+ key := m[1]
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ keys = append(keys, key)
+ if _, err := strconv.Atoi(key); err != nil {
+ allNumeric = false
+ }
+ }
+ if allNumeric {
+ sort.Slice(keys, func(i, j int) bool {
+ a, _ := strconv.Atoi(keys[i])
+ b, _ := strconv.Atoi(keys[j])
+ return a < b
+ })
+ }
+ return keys
+}
+
+func buildHeaderComponent(p TemplateSendParts) map[string]any {
+ headerType := strings.ToUpper(p.HeaderType)
+ switch headerType {
+ case "", "NONE":
+ return nil
+ case "TEXT":
+ // Meta rejects parameters sent for a static header.
+ params := positionalParams(p.HeaderContent, p.Params, "header")
+ if len(params) == 0 {
+ return nil
+ }
+ return map[string]any{
+ "type": "header",
+ "parameters": params,
+ }
+ }
+ return nil
+}
+
+func buildBodyComponent(bodyContent string, params map[string]string) map[string]any {
+ parameters := positionalParams(bodyContent, params, "body")
+ if len(parameters) == 0 {
+ return nil
+ }
+ return map[string]any{
+ "type": "body",
+ "parameters": parameters,
+ }
+}
+
+func buildButtonComponent(index int, b TemplateButton, params map[string]string) map[string]any {
+ if strings.ToUpper(b.Type) != "URL" {
+ return nil
+ }
+ key := "button_url_" + strconv.Itoa(index)
+ val, ok := params[key]
+ if !ok || val == "" {
+ return nil
+ }
+ return map[string]any{
+ "type": "button",
+ "sub_type": "url",
+ "index": strconv.Itoa(index),
+ "parameters": []map[string]any{
+ {"type": "text", "text": val},
+ },
+ }
+}
+
+// positionalParams returns parameter entries for text's placeholders: numeric ones ascending (Meta maps positionally), named ones with parameter_name set.
+func positionalParams(text string, params map[string]string, prefix string) []map[string]any {
+ keys := OrderedPlaceholders(text)
+ if len(keys) == 0 {
+ return nil
+ }
+ out := make([]map[string]any, 0, len(keys))
+ for _, key := range keys {
+ entry := map[string]any{"type": "text", "text": params[prefix+":"+key]}
+ if _, err := strconv.Atoi(key); err != nil {
+ entry["parameter_name"] = key
+ }
+ out = append(out, entry)
+ }
+ return out
+}
diff --git a/internal/whatsapp/components_test.go b/internal/whatsapp/components_test.go
new file mode 100644
index 000000000..c5e36d959
--- /dev/null
+++ b/internal/whatsapp/components_test.go
@@ -0,0 +1,238 @@
+package whatsapp
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestBuildSendComponents_StaticHeaderOmitted(t *testing.T) {
+ parts := TemplateSendParts{
+ HeaderType: "TEXT",
+ HeaderContent: "Order update",
+ BodyContent: "Hi {{1}}, your order {{2}} is now {{3}}",
+ Params: map[string]string{"body:1": "John", "body:2": "ORD-12345", "body:3": "shipped"},
+ }
+ out := BuildSendComponents(parts)
+ if len(out) != 1 {
+ t.Fatalf("expected only body component for static header, got %d components", len(out))
+ }
+ body := out[0]
+ if body["type"] != "body" {
+ t.Fatalf("expected body component, got %q", body["type"])
+ }
+ params, _ := body["parameters"].([]map[string]any)
+ if len(params) != 3 {
+ t.Fatalf("expected 3 body parameters, got %d", len(params))
+ }
+ if params[0]["text"] != "John" || params[1]["text"] != "ORD-12345" || params[2]["text"] != "shipped" {
+ t.Fatalf("body params out of order: %v", params)
+ }
+}
+
+func TestBuildSendComponents_ParameterizedTextHeader(t *testing.T) {
+ parts := TemplateSendParts{
+ HeaderType: "TEXT",
+ HeaderContent: "Order {{1}}",
+ BodyContent: "ETA {{1}}",
+ Params: map[string]string{"header:1": "12345", "body:1": "tomorrow"},
+ }
+ out := BuildSendComponents(parts)
+ if len(out) != 2 {
+ t.Fatalf("expected header+body, got %d", len(out))
+ }
+ hdr := out[0]
+ if hdr["type"] != "header" {
+ t.Fatalf("expected header first, got %q", hdr["type"])
+ }
+ hdrParams, _ := hdr["parameters"].([]map[string]any)
+ if len(hdrParams) != 1 || hdrParams[0]["text"] != "12345" {
+ t.Fatalf("header param wrong: %v", hdrParams)
+ }
+ body := out[1]
+ bodyParams, _ := body["parameters"].([]map[string]any)
+ if len(bodyParams) != 1 || bodyParams[0]["text"] != "tomorrow" {
+ t.Fatalf("body param wrong: %v", bodyParams)
+ }
+}
+
+func TestBuildSendComponents_NamedParameters(t *testing.T) {
+ parts := TemplateSendParts{
+ BodyContent: "Hi {{name}}, order {{order_id}}",
+ Params: map[string]string{"body:name": "John", "body:order_id": "12345"},
+ }
+ out := BuildSendComponents(parts)
+ if len(out) != 1 {
+ t.Fatalf("expected one body component, got %d", len(out))
+ }
+ params, _ := out[0]["parameters"].([]map[string]any)
+ if len(params) != 2 {
+ t.Fatalf("expected 2 params, got %d", len(params))
+ }
+ want := []map[string]any{
+ {"type": "text", "parameter_name": "name", "text": "John"},
+ {"type": "text", "parameter_name": "order_id", "text": "12345"},
+ }
+ if !reflect.DeepEqual(params, want) {
+ t.Fatalf("named params mismatch.\n got: %v\nwant: %v", params, want)
+ }
+}
+
+func TestBuildSendComponents_URLButton(t *testing.T) {
+ parts := TemplateSendParts{
+ BodyContent: "Track your shipment",
+ Buttons: []TemplateButton{
+ {Type: "URL", Text: "Track", URL: "https://example.com/{{1}}"},
+ },
+ Params: map[string]string{"button_url_0": "12345"},
+ }
+ out := BuildSendComponents(parts)
+ if len(out) != 1 {
+ t.Fatalf("expected only button component, got %d", len(out))
+ }
+ btn := out[0]
+ if btn["sub_type"] != "url" || btn["index"] != "0" {
+ t.Fatalf("button shape wrong: %v", btn)
+ }
+}
+
+func TestVerifySignature(t *testing.T) {
+ body := []byte(`{"hello":"world"}`)
+ secret := "topsecret"
+ // echo -n '{"hello":"world"}' | openssl dgst -sha256 -hmac topsecret
+ good := "sha256=afd00617ceb8f63e65ea5c310f06bf78c3901e7a713db532e25da26ad63c7236"
+ bad := "sha256=deadbeef"
+ if !VerifySignature(body, good, secret) {
+ t.Fatalf("expected valid signature to verify")
+ }
+ if VerifySignature(body, bad, secret) {
+ t.Fatalf("expected bad signature to fail")
+ }
+ if VerifySignature(body, "", secret) {
+ t.Fatalf("expected empty header to fail")
+ }
+ if VerifySignature(body, good, "") {
+ t.Fatalf("expected empty secret to fail")
+ }
+}
+
+func TestOrderedPlaceholders(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ want []string
+ }{
+ {"empty text", "", nil},
+ {"no placeholders", "plain copy", nil},
+ {"positional in order", "Hi {{1}}, order {{2}}", []string{"1", "2"}},
+ {"named in order", "Hi {{name}}, order {{order_id}}", []string{"name", "order_id"}},
+ {"repeats appear once", "{{name}} and {{name}} again", []string{"name"}},
+ {"order follows first use", "{{b}} then {{a}} then {{b}}", []string{"b", "a"}},
+ {"malformed braces ignored", "{{}} {{ x }} {{a-b}}", nil},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := OrderedPlaceholders(tc.text)
+ if len(got) != len(tc.want) {
+ t.Fatalf("expected %v, got %v", tc.want, got)
+ }
+ for i := range got {
+ if got[i] != tc.want[i] {
+ t.Fatalf("expected %v, got %v", tc.want, got)
+ }
+ }
+ })
+ }
+}
+
+// A media header takes its file at send time, so it contributes no component here.
+func TestBuildSendComponents_MediaHeaderSkipped(t *testing.T) {
+ comps := BuildSendComponents(TemplateSendParts{
+ HeaderType: "IMAGE",
+ HeaderContent: "ignored",
+ BodyContent: "Hi there",
+ })
+ if len(comps) != 0 {
+ t.Fatalf("expected no components, got %+v", comps)
+ }
+}
+
+func TestBuildSendComponents_TextHeaderWithoutParams(t *testing.T) {
+ comps := BuildSendComponents(TemplateSendParts{
+ HeaderType: "TEXT",
+ HeaderContent: "Static header",
+ BodyContent: "Hi there",
+ })
+ if len(comps) != 0 {
+ t.Fatalf("expected no components for a static header and body, got %+v", comps)
+ }
+}
+
+func TestBuildSendComponents_ButtonVariants(t *testing.T) {
+ tests := []struct {
+ name string
+ buttons []TemplateButton
+ params map[string]string
+ want int
+ }{
+ {
+ name: "quick reply buttons take no parameters",
+ buttons: []TemplateButton{{Type: "QUICK_REPLY", Text: "Yes"}},
+ params: map[string]string{"button_url_0": "ignored"},
+ want: 0,
+ },
+ {
+ name: "dynamic url button with no value is skipped",
+ buttons: []TemplateButton{{Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"}},
+ params: nil,
+ want: 0,
+ },
+ {
+ name: "dynamic url button with an empty value is skipped",
+ buttons: []TemplateButton{{Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"}},
+ params: map[string]string{"button_url_0": ""},
+ want: 0,
+ },
+ {
+ name: "dynamic url button with a value is sent",
+ buttons: []TemplateButton{{Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"}},
+ params: map[string]string{"button_url_0": "A1"},
+ want: 1,
+ },
+ {
+ name: "only the dynamic button of several is sent",
+ buttons: []TemplateButton{
+ {Type: "QUICK_REPLY", Text: "No"},
+ {Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"},
+ },
+ params: map[string]string{"button_url_1": "A1"},
+ want: 1,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ comps := BuildSendComponents(TemplateSendParts{
+ BodyContent: "Hi there",
+ Buttons: tc.buttons,
+ Params: tc.params,
+ })
+ if len(comps) != tc.want {
+ t.Fatalf("expected %d components, got %+v", tc.want, comps)
+ }
+ })
+ }
+}
+
+// The button index Meta needs is the button's position in the template, not the order it was filled.
+func TestBuildSendComponents_ButtonIndexMatchesPosition(t *testing.T) {
+ comps := BuildSendComponents(TemplateSendParts{
+ BodyContent: "Hi",
+ Buttons: []TemplateButton{
+ {Type: "QUICK_REPLY", Text: "No"},
+ {Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"},
+ },
+ Params: map[string]string{"button_url_1": "A1"},
+ })
+ if len(comps) != 1 || comps[0]["index"] != "1" || comps[0]["sub_type"] != "url" {
+ t.Fatalf("unexpected button component: %+v", comps)
+ }
+}
diff --git a/internal/whatsapp/types.go b/internal/whatsapp/types.go
new file mode 100644
index 000000000..474043d48
--- /dev/null
+++ b/internal/whatsapp/types.go
@@ -0,0 +1,172 @@
+// Package whatsapp provides a client for the WhatsApp Cloud API and helpers for parsing Meta webhook payloads.
+package whatsapp
+
+import "time"
+
+const DefaultAPIVersion = "v25.0"
+
+// Account holds the per-inbox Meta Graph API credentials, already decrypted at the call site.
+type Account struct {
+ PhoneNumberID string
+ WABAID string
+ AccessToken string
+ AppSecret string
+ APIVersion string
+}
+
+type MetaAPIError struct {
+ StatusCode int `json:"-"`
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code int `json:"code"`
+ Subcode int `json:"error_subcode"`
+ UserMsg string `json:"error_user_msg"`
+ FBTraceID string `json:"fbtrace_id"`
+}
+
+type metaErrorEnvelope struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code int `json:"code"`
+ ErrorSubcode int `json:"error_subcode"`
+ ErrorUserMsg string `json:"error_user_msg"`
+ FBTraceID string `json:"fbtrace_id"`
+ } `json:"error"`
+}
+
+type SendResponse struct {
+ MessagingProduct string `json:"messaging_product"`
+ Contacts []struct {
+ Input string `json:"input"`
+ WAID string `json:"wa_id"`
+ } `json:"contacts"`
+ Messages []struct {
+ ID string `json:"id"`
+ MessageStatus string `json:"message_status"`
+ } `json:"messages"`
+}
+
+type MediaInfo struct {
+ URL string `json:"url"`
+ MimeType string `json:"mime_type"`
+ SHA256 string `json:"sha256"`
+ FileSize int64 `json:"file_size"`
+ ID string `json:"id"`
+ MessagingProduct string `json:"messaging_product"`
+}
+
+type UploadMediaResponse struct {
+ ID string `json:"id"`
+}
+
+type MetaTemplate struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Language string `json:"language"`
+ Category string `json:"category"`
+ Status string `json:"status"`
+ Components []TemplateComponent `json:"components"`
+ QualityScore any `json:"quality_score,omitempty"`
+ RejectedReason string `json:"rejected_reason,omitempty"`
+}
+
+type TemplateComponent struct {
+ Type string `json:"type"`
+ Format string `json:"format,omitempty"`
+ Text string `json:"text,omitempty"`
+ Example map[string]any `json:"example,omitempty"`
+ Buttons []TemplateButton `json:"buttons,omitempty"`
+}
+
+type TemplateButton struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ URL string `json:"url,omitempty"`
+ PhoneNumber string `json:"phone_number,omitempty"`
+ Example []string `json:"example,omitempty"`
+}
+
+type TemplateSubmission struct {
+ Name string `json:"name"`
+ Language string `json:"language"`
+ Category string `json:"category"`
+ ParameterFormat string `json:"parameter_format,omitempty"`
+ Components []TemplateComponent `json:"components"`
+}
+
+// TemplateEdit is the payload sent to Meta when editing a template; name and language are immutable on Meta so they are omitted.
+type TemplateEdit struct {
+ Category string `json:"category,omitempty"`
+ ParameterFormat string `json:"parameter_format,omitempty"`
+ Components []TemplateComponent `json:"components"`
+}
+
+type phoneNumberListResponse struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ Paging struct {
+ Next string `json:"next"`
+ } `json:"paging"`
+}
+
+type templateListResponse struct {
+ Data []MetaTemplate `json:"data"`
+ Paging struct {
+ Cursors struct {
+ Before string `json:"before"`
+ After string `json:"after"`
+ } `json:"cursors"`
+ Next string `json:"next"`
+ } `json:"paging"`
+}
+
+type ParsedMessage struct {
+ From string
+ ID string
+ Timestamp time.Time
+ Type string
+ Text string
+ ButtonReplyID string
+ ListReplyID string
+ MediaID string
+ MediaMimeType string
+ Caption string
+ Filename string
+ ContactName string
+ PhoneNumberID string
+ ContextID string
+ SystemType string
+ SystemNewWAID string
+}
+
+type ParsedStatus struct {
+ MessageID string
+ Status string
+ Timestamp time.Time
+ UserMsg string
+}
+
+type ParsedTemplateStatus struct {
+ WABAID string
+ Event string
+ TemplateName string
+ Language string
+ Reason string
+ MetaTemplateID string
+}
+
+func (a Account) Version() string {
+ if a.APIVersion == "" {
+ return DefaultAPIVersion
+ }
+ return a.APIVersion
+}
+
+func (e *MetaAPIError) Error() string {
+ if e.UserMsg != "" {
+ return e.UserMsg
+ }
+ return e.Message
+}
diff --git a/internal/whatsapp/webhook.go b/internal/whatsapp/webhook.go
new file mode 100644
index 000000000..dc28fba3c
--- /dev/null
+++ b/internal/whatsapp/webhook.go
@@ -0,0 +1,358 @@
+package whatsapp
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type WebhookPayload struct {
+ Object string `json:"object"`
+ Entry []WebhookEntry `json:"entry"`
+}
+
+type WebhookEntry struct {
+ ID string `json:"id"`
+ Changes []WebhookChange `json:"changes"`
+}
+
+type WebhookChange struct {
+ Field string `json:"field"`
+ Value WebhookValue `json:"value"`
+}
+
+type WebhookValue struct {
+ MessagingProduct string `json:"messaging_product"`
+ Metadata WebhookMetadata `json:"metadata"`
+ Contacts []WebhookContact `json:"contacts"`
+ Messages []WebhookMessage `json:"messages"`
+ Statuses []WebhookStatus `json:"statuses"`
+ Event string `json:"event"`
+ MessageTemplateID any `json:"message_template_id"`
+ MessageTemplateName string `json:"message_template_name"`
+ MessageTemplateLanguage string `json:"message_template_language"`
+ Reason string `json:"reason"`
+ Errors []WebhookError `json:"errors"`
+}
+
+type WebhookMetadata struct {
+ DisplayPhoneNumber string `json:"display_phone_number"`
+ PhoneNumberID string `json:"phone_number_id"`
+}
+
+type WebhookContact struct {
+ Profile struct {
+ Name string `json:"name"`
+ } `json:"profile"`
+ WAID string `json:"wa_id"`
+}
+
+type WebhookMessage struct {
+ From string `json:"from"`
+ ID string `json:"id"`
+ Timestamp string `json:"timestamp"`
+ Type string `json:"type"`
+
+ Text *struct {
+ Body string `json:"body"`
+ } `json:"text,omitempty"`
+
+ Image *WebhookMedia `json:"image,omitempty"`
+ Video *WebhookMedia `json:"video,omitempty"`
+ Audio *WebhookMedia `json:"audio,omitempty"`
+ Document *WebhookMedia `json:"document,omitempty"`
+ Sticker *WebhookMedia `json:"sticker,omitempty"`
+ Voice *WebhookMedia `json:"voice,omitempty"`
+
+ Interactive *struct {
+ Type string `json:"type"`
+ ButtonReply *struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ } `json:"button_reply,omitempty"`
+ ListReply *struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ } `json:"list_reply,omitempty"`
+ } `json:"interactive,omitempty"`
+
+ Button *struct {
+ Text string `json:"text"`
+ Payload string `json:"payload"`
+ } `json:"button,omitempty"`
+
+ Location *struct {
+ Latitude json.Number `json:"latitude"`
+ Longitude json.Number `json:"longitude"`
+ Name string `json:"name,omitempty"`
+ Address string `json:"address,omitempty"`
+ } `json:"location,omitempty"`
+
+ Contacts []struct {
+ Name struct {
+ FormattedName string `json:"formatted_name"`
+ } `json:"name"`
+ Phones []struct {
+ Phone string `json:"phone"`
+ WAID string `json:"wa_id,omitempty"`
+ } `json:"phones,omitempty"`
+ } `json:"contacts,omitempty"`
+
+ System *struct {
+ Type string `json:"type"`
+ Body string `json:"body"`
+ WAID string `json:"wa_id,omitempty"`
+ NewWAID string `json:"new_wa_id,omitempty"`
+ Customer string `json:"customer,omitempty"`
+ } `json:"system,omitempty"`
+
+ Context *struct {
+ From string `json:"from"`
+ ID string `json:"id"`
+ } `json:"context,omitempty"`
+}
+
+type WebhookMedia struct {
+ ID string `json:"id"`
+ MimeType string `json:"mime_type"`
+ SHA256 string `json:"sha256"`
+ Caption string `json:"caption,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ Voice bool `json:"voice,omitempty"`
+}
+
+type WebhookStatus struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Timestamp string `json:"timestamp"`
+ RecipientID string `json:"recipient_id"`
+ Errors []WebhookError `json:"errors,omitempty"`
+ Conversation any `json:"conversation,omitempty"`
+ Pricing any `json:"pricing,omitempty"`
+}
+
+type WebhookError struct {
+ Code int `json:"code"`
+ Subcode int `json:"error_subcode"`
+ Title string `json:"title"`
+ Message string `json:"message"`
+ UserMsg string `json:"error_user_msg"`
+ ErrorData struct {
+ Details string `json:"details"`
+ } `json:"error_data"`
+ FBTraceID string `json:"fbtrace_id"`
+}
+
+func ParsePayload(body []byte) (*WebhookPayload, error) {
+ var p WebhookPayload
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.UseNumber()
+ if err := dec.Decode(&p); err != nil {
+ return nil, fmt.Errorf("decoding webhook payload: %w", err)
+ }
+ return &p, nil
+}
+
+func (p *WebhookPayload) ExtractMessages() []ParsedMessage {
+ var out []ParsedMessage
+ for _, e := range p.Entry {
+ for _, c := range e.Changes {
+ if c.Field != "messages" {
+ continue
+ }
+ contactName := ""
+ if len(c.Value.Contacts) > 0 {
+ contactName = c.Value.Contacts[0].Profile.Name
+ }
+ for _, m := range c.Value.Messages {
+ pm := ParsedMessage{
+ From: m.From,
+ ID: m.ID,
+ Timestamp: parseUnixSeconds(m.Timestamp),
+ Type: m.Type,
+ ContactName: contactName,
+ PhoneNumberID: c.Value.Metadata.PhoneNumberID,
+ }
+ if m.Context != nil {
+ pm.ContextID = m.Context.ID
+ }
+ switch m.Type {
+ case "text":
+ if m.Text != nil {
+ pm.Text = m.Text.Body
+ }
+ case "image":
+ applyMedia(&pm, m.Image)
+ case "video":
+ applyMedia(&pm, m.Video)
+ case "audio":
+ applyMedia(&pm, m.Audio)
+ case "voice":
+ applyMedia(&pm, m.Voice)
+ case "document":
+ applyMedia(&pm, m.Document)
+ case "sticker":
+ applyMedia(&pm, m.Sticker)
+ case "interactive":
+ if m.Interactive != nil {
+ if m.Interactive.ButtonReply != nil {
+ pm.ButtonReplyID = m.Interactive.ButtonReply.ID
+ pm.Text = m.Interactive.ButtonReply.Title
+ }
+ if m.Interactive.ListReply != nil {
+ pm.ListReplyID = m.Interactive.ListReply.ID
+ pm.Text = m.Interactive.ListReply.Title
+ }
+ }
+ case "button":
+ if m.Button != nil {
+ pm.ButtonReplyID = m.Button.Payload
+ pm.Text = m.Button.Text
+ }
+ case "location":
+ if m.Location != nil {
+ pm.Text = locationText(m.Location.Name, m.Location.Address, m.Location.Latitude.String(), m.Location.Longitude.String())
+ }
+ case "contacts":
+ var lines []string
+ for _, c := range m.Contacts {
+ phone := ""
+ if len(c.Phones) > 0 {
+ phone = c.Phones[0].Phone
+ }
+ lines = append(lines, strings.TrimSpace(strings.TrimSuffix(c.Name.FormattedName+" "+phone, " ")))
+ }
+ pm.Text = strings.Join(lines, "\n")
+ case "system":
+ if m.System != nil {
+ pm.SystemType = m.System.Type
+ pm.SystemNewWAID = firstNonEmptyStr(m.System.NewWAID, m.System.WAID)
+ pm.Text = m.System.Body
+ }
+ }
+ out = append(out, pm)
+ }
+ }
+ }
+ return out
+}
+
+func (p *WebhookPayload) ExtractStatuses() []ParsedStatus {
+ var out []ParsedStatus
+ for _, e := range p.Entry {
+ for _, c := range e.Changes {
+ if c.Field != "messages" {
+ continue
+ }
+ for _, s := range c.Value.Statuses {
+ ps := ParsedStatus{
+ MessageID: s.ID,
+ Status: s.Status,
+ Timestamp: parseUnixSeconds(s.Timestamp),
+ }
+ if len(s.Errors) > 0 {
+ err := s.Errors[0]
+ ps.UserMsg = firstNonEmptyStr(err.ErrorData.Details, err.UserMsg, err.Message)
+ }
+ out = append(out, ps)
+ }
+ }
+ }
+ return out
+}
+
+func (p *WebhookPayload) ExtractTemplateStatusUpdates() []ParsedTemplateStatus {
+ var out []ParsedTemplateStatus
+ for _, e := range p.Entry {
+ for _, c := range e.Changes {
+ if c.Field != "message_template_status_update" {
+ continue
+ }
+ out = append(out, ParsedTemplateStatus{
+ WABAID: e.ID,
+ Event: c.Value.Event,
+ TemplateName: c.Value.MessageTemplateName,
+ Language: c.Value.MessageTemplateLanguage,
+ Reason: c.Value.Reason,
+ MetaTemplateID: stringifyTemplateID(c.Value.MessageTemplateID),
+ })
+ }
+ }
+ return out
+}
+
+// VerifySignature validates Meta's full X-Hub-Signature-256 header value (e.g. "sha256=abc...") against the raw body.
+func VerifySignature(body []byte, signatureHeader, appSecret string) bool {
+ if appSecret == "" || signatureHeader == "" {
+ return false
+ }
+ parts := strings.SplitN(signatureHeader, "=", 2)
+ if len(parts) != 2 || parts[0] != "sha256" {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(appSecret))
+ mac.Write(body)
+ expected := hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(expected), []byte(parts[1]))
+}
+
+func applyMedia(pm *ParsedMessage, m *WebhookMedia) {
+ if m == nil {
+ return
+ }
+ pm.MediaID = m.ID
+ pm.MediaMimeType = m.MimeType
+ pm.Caption = m.Caption
+ pm.Filename = m.Filename
+}
+
+func locationText(name, address, lat, lng string) string {
+ var lines []string
+ if label := strings.TrimSpace(strings.Trim(name+", "+address, ", ")); label != "" {
+ lines = append(lines, label)
+ }
+ if lat != "" && lng != "" {
+ lines = append(lines, "https://www.google.com/maps?q="+lat+","+lng)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func firstNonEmptyStr(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func parseUnixSeconds(s string) time.Time {
+ if s == "" {
+ return time.Time{}
+ }
+ n, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return time.Time{}
+ }
+ return time.Unix(n, 0).UTC()
+}
+
+func stringifyTemplateID(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case float64:
+ return strconv.FormatInt(int64(t), 10)
+ case json.Number:
+ return t.String()
+ default:
+ return ""
+ }
+}
diff --git a/internal/whatsapp/webhook_test.go b/internal/whatsapp/webhook_test.go
new file mode 100644
index 000000000..bc13fe73d
--- /dev/null
+++ b/internal/whatsapp/webhook_test.go
@@ -0,0 +1,503 @@
+package whatsapp
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestExtractMessages_Text(t *testing.T) {
+ body := []byte(`{
+ "object": "whatsapp_business_account",
+ "entry": [{
+ "id": "WABA-1",
+ "changes": [{
+ "field": "messages",
+ "value": {
+ "messaging_product": "whatsapp",
+ "metadata": {"display_phone_number": "+1", "phone_number_id": "PN-1"},
+ "contacts": [{"profile": {"name": "Jane Doe"}, "wa_id": "919876543210"}],
+ "messages": [{
+ "from": "919876543210",
+ "id": "wamid.ABC",
+ "timestamp": "1716000000",
+ "type": "text",
+ "text": {"body": "hello"}
+ }]
+ }
+ }]
+ }]
+ }`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ msgs := p.ExtractMessages()
+ if len(msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(msgs))
+ }
+ m := msgs[0]
+ if m.From != "919876543210" || m.ID != "wamid.ABC" || m.Type != "text" || m.Text != "hello" {
+ t.Fatalf("unexpected parsed message: %+v", m)
+ }
+ if m.ContactName != "Jane Doe" {
+ t.Fatalf("expected contact name Jane Doe, got %q", m.ContactName)
+ }
+}
+
+func TestExtractMessages_ImageWithCaption(t *testing.T) {
+ body := []byte(`{
+ "entry": [{
+ "changes": [{
+ "field": "messages",
+ "value": {
+ "messages": [{
+ "from": "1", "id": "id1", "timestamp": "1716000000", "type": "image",
+ "image": {"id": "media-1", "mime_type": "image/jpeg", "caption": "see this"}
+ }]
+ }
+ }]
+ }]
+ }`)
+ p, _ := ParsePayload(body)
+ m := p.ExtractMessages()[0]
+ if m.MediaID != "media-1" || m.MediaMimeType != "image/jpeg" || m.Caption != "see this" {
+ t.Fatalf("media not extracted correctly: %+v", m)
+ }
+}
+
+func TestExtractMessages_TemplateQuickReplyButton(t *testing.T) {
+ body := []byte(`{
+ "entry": [{
+ "changes": [{
+ "field": "messages",
+ "value": {
+ "messages": [{
+ "from": "1", "id": "id1", "timestamp": "1716000000", "type": "button",
+ "button": {"text": "Yes, confirm", "payload": "confirm-payload"}
+ }]
+ }
+ }]
+ }]
+ }`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ m := p.ExtractMessages()[0]
+ if m.Text != "Yes, confirm" || m.ButtonReplyID != "confirm-payload" {
+ t.Fatalf("button reply not extracted: %+v", m)
+ }
+}
+
+func TestExtractStatuses(t *testing.T) {
+ body := []byte(`{
+ "entry": [{
+ "changes": [{
+ "field": "messages",
+ "value": {
+ "statuses": [{
+ "id": "wamid.OUT",
+ "status": "delivered",
+ "timestamp": "1716000000",
+ "recipient_id": "919876543210"
+ }]
+ }
+ }]
+ }]
+ }`)
+ p, _ := ParsePayload(body)
+ st := p.ExtractStatuses()
+ if len(st) != 1 || st[0].MessageID != "wamid.OUT" || st[0].Status != "delivered" {
+ t.Fatalf("unexpected statuses: %+v", st)
+ }
+}
+
+func TestExtractTemplateStatusUpdate(t *testing.T) {
+ body := []byte(`{
+ "entry": [{
+ "id": "WABA-1",
+ "changes": [{
+ "field": "message_template_status_update",
+ "value": {
+ "event": "APPROVED",
+ "message_template_id": 1234567890,
+ "message_template_name": "order_status",
+ "message_template_language": "en_US"
+ }
+ }]
+ }]
+ }`)
+ p, _ := ParsePayload(body)
+ ts := p.ExtractTemplateStatusUpdates()
+ if len(ts) != 1 {
+ t.Fatalf("expected 1 template status, got %d", len(ts))
+ }
+ if ts[0].Event != "APPROVED" || ts[0].TemplateName != "order_status" ||
+ ts[0].Language != "en_US" || ts[0].MetaTemplateID != "1234567890" {
+ t.Fatalf("unexpected template status: %+v", ts[0])
+ }
+}
+
+func TestExtractTemplateStatusUpdate_LargeID(t *testing.T) {
+ body := []byte(`{
+ "entry": [{
+ "id": "WABA-1",
+ "changes": [{
+ "field": "message_template_status_update",
+ "value": {
+ "event": "APPROVED",
+ "message_template_id": 123456789012345678,
+ "message_template_name": "order_status",
+ "message_template_language": "en_US"
+ }
+ }]
+ }]
+ }`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ ts := p.ExtractTemplateStatusUpdates()
+ if len(ts) != 1 || ts[0].MetaTemplateID != "123456789012345678" {
+ t.Fatalf("large template id lost precision: %+v", ts)
+ }
+}
+
+func TestParsePayload_InvalidJSON(t *testing.T) {
+ if _, err := ParsePayload([]byte(`{"entry": [`)); err == nil {
+ t.Fatal("expected error for malformed json, got nil")
+ }
+}
+
+func TestParsePayload_EmptyBody(t *testing.T) {
+ if _, err := ParsePayload(nil); err == nil {
+ t.Fatal("expected error for empty body, got nil")
+ }
+}
+
+func TestExtractMessages_MediaTypes(t *testing.T) {
+ cases := []struct {
+ name string
+ msgType string
+ field string
+ mime string
+ filename string
+ }{
+ {name: "document", msgType: "document", field: "document", mime: "application/pdf", filename: "invoice.pdf"},
+ {name: "audio", msgType: "audio", field: "audio", mime: "audio/ogg"},
+ {name: "voice", msgType: "voice", field: "voice", mime: "audio/ogg"},
+ {name: "video", msgType: "video", field: "video", mime: "video/mp4"},
+ {name: "sticker", msgType: "sticker", field: "sticker", mime: "image/webp"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ media := `{"id":"media-x","mime_type":"` + c.mime + `"`
+ if c.filename != "" {
+ media += `,"filename":"` + c.filename + `"`
+ }
+ media += `}`
+ p := wrapValue(t, `{"messages":[{"from":"1","id":"id1","timestamp":"1716000000","type":"`+c.msgType+`","`+c.field+`":`+media+`}]}`)
+ m := p.ExtractMessages()[0]
+ if m.Type != c.msgType || m.MediaID != "media-x" || m.MediaMimeType != c.mime {
+ t.Fatalf("%s not extracted: %+v", c.name, m)
+ }
+ if c.filename != "" && m.Filename != c.filename {
+ t.Fatalf("%s filename = %q, want %q", c.name, m.Filename, c.filename)
+ }
+ })
+ }
+}
+
+func TestExtractMessages_InteractiveButtonReply(t *testing.T) {
+ p := wrapValue(t, `{"messages":[{"from":"1","id":"id1","timestamp":"1716000000","type":"interactive",
+ "interactive":{"type":"button_reply","button_reply":{"id":"btn-1","title":"Track order"}}}]}`)
+ m := p.ExtractMessages()[0]
+ if m.ButtonReplyID != "btn-1" || m.Text != "Track order" {
+ t.Fatalf("interactive button_reply not extracted: %+v", m)
+ }
+}
+
+func TestExtractMessages_InteractiveListReply(t *testing.T) {
+ p := wrapValue(t, `{"messages":[{"from":"1","id":"id1","timestamp":"1716000000","type":"interactive",
+ "interactive":{"type":"list_reply","list_reply":{"id":"opt-2","title":"Refund","description":"Request a refund"}}}]}`)
+ m := p.ExtractMessages()[0]
+ if m.ListReplyID != "opt-2" || m.Text != "Refund" {
+ t.Fatalf("interactive list_reply not extracted: %+v", m)
+ }
+}
+
+func TestExtractMessages_ReplyContext(t *testing.T) {
+ p := wrapValue(t, `{"messages":[{"from":"1","id":"id2","timestamp":"1716000000","type":"text",
+ "text":{"body":"replying"},"context":{"from":"1","id":"wamid.QUOTED"}}]}`)
+ m := p.ExtractMessages()[0]
+ if m.ContextID != "wamid.QUOTED" {
+ t.Fatalf("reply context not extracted: %+v", m)
+ }
+}
+
+func TestExtractMessages_UnsupportedTypePreserved(t *testing.T) {
+ p := wrapValue(t, `{"messages":[{"from":"1","id":"id3","timestamp":"1716000000","type":"unsupported",
+ "errors":[{"code":131051,"title":"Unsupported message type"}]}]}`)
+ m := p.ExtractMessages()[0]
+ if m.Type != "unsupported" {
+ t.Fatalf("expected type preserved as unsupported, got %q", m.Type)
+ }
+}
+
+func TestExtractMessages_MultipleMessagesAndEntries(t *testing.T) {
+ body := []byte(`{"entry":[
+ {"id":"WABA-1","changes":[{"field":"messages","value":{"messages":[
+ {"from":"1","id":"a","timestamp":"1716000000","type":"text","text":{"body":"one"}},
+ {"from":"1","id":"b","timestamp":"1716000001","type":"text","text":{"body":"two"}}
+ ]}}]},
+ {"id":"WABA-1","changes":[{"field":"messages","value":{"messages":[
+ {"from":"2","id":"c","timestamp":"1716000002","type":"text","text":{"body":"three"}}
+ ]}}]}
+ ]}`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ msgs := p.ExtractMessages()
+ if len(msgs) != 3 {
+ t.Fatalf("expected 3 messages across entries, got %d", len(msgs))
+ }
+ if msgs[0].ID != "a" || msgs[1].ID != "b" || msgs[2].ID != "c" {
+ t.Fatalf("messages out of order or missing: %+v", msgs)
+ }
+}
+
+func TestExtractMessages_NonMessageFieldSkipped(t *testing.T) {
+ body := []byte(`{"entry":[{"id":"WABA-1","changes":[{"field":"message_template_status_update",
+ "value":{"event":"APPROVED","message_template_name":"x"}}]}]}`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if msgs := p.ExtractMessages(); len(msgs) != 0 {
+ t.Fatalf("expected no messages for non-messages field, got %d", len(msgs))
+ }
+}
+
+func TestExtractStatuses_FailedWithError(t *testing.T) {
+ p := wrapValue(t, `{"statuses":[{"id":"wamid.OUT","status":"failed","timestamp":"1716000000","recipient_id":"919876543210",
+ "errors":[{"code":131047,"error_subcode":2655000,"title":"Re-engagement message",
+ "error_data":{"details":"Message failed to send because more than 24 hours have passed."},"fbtrace_id":"trace-1"}]}]}`)
+ st := p.ExtractStatuses()
+ if len(st) != 1 {
+ t.Fatalf("expected 1 status, got %d", len(st))
+ }
+ s := st[0]
+ if s.Status != "failed" {
+ t.Fatalf("failed status fields not extracted: %+v", s)
+ }
+ if s.UserMsg != "Message failed to send because more than 24 hours have passed." {
+ t.Fatalf("expected error_data.details as UserMsg, got %q", s.UserMsg)
+ }
+}
+
+func TestExtractStatuses_UnknownStatusDoesNotBreak(t *testing.T) {
+ p := wrapValue(t, `{"statuses":[{"id":"wamid.OUT","status":"deleted","timestamp":"1716000000","recipient_id":"1"}]}`)
+ st := p.ExtractStatuses()
+ if len(st) != 1 || st[0].Status != "deleted" {
+ t.Fatalf("unknown status not passed through: %+v", st)
+ }
+}
+
+func TestExtractMessages_Location(t *testing.T) {
+ tests := []struct {
+ name string
+ payload string
+ want string
+ }{
+ {
+ name: "name and address",
+ payload: `{"latitude":12.9716,"longitude":77.5946,"name":"Office","address":"MG Road"}`,
+ want: "Office, MG Road\nhttps://www.google.com/maps?q=12.9716,77.5946",
+ },
+ {
+ name: "coordinates only",
+ payload: `{"latitude":12.9716,"longitude":77.5946}`,
+ want: "https://www.google.com/maps?q=12.9716,77.5946",
+ },
+ {
+ name: "name without address",
+ payload: `{"latitude":1,"longitude":2,"name":"Office"}`,
+ want: "Office\nhttps://www.google.com/maps?q=1,2",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.LOC","timestamp":"1716000000","type":"location","location":`+tc.payload+`}`)
+ if msgs[0].Text != tc.want {
+ t.Fatalf("expected %q, got %q", tc.want, msgs[0].Text)
+ }
+ })
+ }
+}
+
+func TestExtractMessages_LocationMissingPayload(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.LOC2","timestamp":"1716000000","type":"location"}`)
+ if msgs[0].Text != "" {
+ t.Fatalf("expected no text, got %q", msgs[0].Text)
+ }
+}
+
+func TestExtractMessages_SharedContacts(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.CON","timestamp":"1716000000","type":"contacts","contacts":[
+ {"name":{"formatted_name":"Anita Desai"},"phones":[{"phone":"+91 98765 43210","wa_id":"919876543210"}]},
+ {"name":{"formatted_name":"No Phone"}}
+ ]}`)
+ want := "Anita Desai +91 98765 43210\nNo Phone"
+ if msgs[0].Text != want {
+ t.Fatalf("expected %q, got %q", want, msgs[0].Text)
+ }
+}
+
+// A customer moving to a new number arrives as a system event carrying the new wa_id.
+func TestExtractMessages_SystemNumberChange(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"919876543210","id":"wamid.SYS","timestamp":"1716000000","type":"system","system":{"type":"user_changed_number","body":"changed number","new_wa_id":"919999999999"}}`)
+ m := msgs[0]
+ if m.SystemType != "user_changed_number" || m.SystemNewWAID != "919999999999" || m.Text != "changed number" {
+ t.Fatalf("unexpected system message: %+v", m)
+ }
+}
+
+// Older payloads carry wa_id instead of new_wa_id.
+func TestExtractMessages_SystemNumberChangeLegacyField(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"919876543210","id":"wamid.SYS2","timestamp":"1716000000","type":"system","system":{"type":"user_changed_number","body":"changed","wa_id":"919999999999"}}`)
+ if msgs[0].SystemNewWAID != "919999999999" {
+ t.Fatalf("expected the legacy wa_id to be used, got %q", msgs[0].SystemNewWAID)
+ }
+}
+
+func TestExtractMessages_SystemMissingPayload(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.SYS3","timestamp":"1716000000","type":"system"}`)
+ if msgs[0].SystemType != "" || msgs[0].SystemNewWAID != "" {
+ t.Fatalf("unexpected system message: %+v", msgs[0])
+ }
+}
+
+func TestExtractMessages_MediaWithoutPayload(t *testing.T) {
+ for _, typ := range []string{"image", "video", "audio", "voice", "document", "sticker"} {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.M","timestamp":"1716000000","type":"`+typ+`"}`)
+ if msgs[0].MediaID != "" {
+ t.Errorf("%s: expected no media id, got %q", typ, msgs[0].MediaID)
+ }
+ }
+}
+
+func TestExtractMessages_TextWithoutPayload(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.T","timestamp":"1716000000","type":"text"}`)
+ if msgs[0].Text != "" {
+ t.Fatalf("expected no text, got %q", msgs[0].Text)
+ }
+}
+
+func TestExtractMessages_InteractiveWithoutPayload(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.I","timestamp":"1716000000","type":"interactive"}`)
+ if msgs[0].Text != "" || msgs[0].ButtonReplyID != "" || msgs[0].ListReplyID != "" {
+ t.Fatalf("unexpected message: %+v", msgs[0])
+ }
+}
+
+func TestExtractMessages_ButtonWithoutPayload(t *testing.T) {
+ msgs := messagesFrom(t, `{"from":"91","id":"wamid.B","timestamp":"1716000000","type":"button"}`)
+ if msgs[0].Text != "" || msgs[0].ButtonReplyID != "" {
+ t.Fatalf("unexpected message: %+v", msgs[0])
+ }
+}
+
+// Statuses and template updates share the payload shape, so each extractor must ignore the other's field.
+func TestExtractorsIgnoreForeignFields(t *testing.T) {
+ body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"WABA-1","changes":[
+ {"field":"message_template_status_update","value":{"event":"APPROVED","message_template_name":"t","message_template_language":"en_US"}},
+ {"field":"messages","value":{"statuses":[{"id":"wamid.S","status":"delivered","timestamp":"1716000000"}]}}
+ ]}]}`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(p.ExtractMessages()) != 0 {
+ t.Fatal("expected no messages")
+ }
+ if got := p.ExtractStatuses(); len(got) != 1 || got[0].MessageID != "wamid.S" {
+ t.Fatalf("unexpected statuses: %+v", got)
+ }
+ if got := p.ExtractTemplateStatusUpdates(); len(got) != 1 || got[0].TemplateName != "t" {
+ t.Fatalf("unexpected template updates: %+v", got)
+ }
+}
+
+func TestVerifySignatureRejectsMalformedHeaders(t *testing.T) {
+ body := []byte(`{"a":1}`)
+ for _, header := range []string{"abc", "sha1=abc", "=abc", "sha256"} {
+ if VerifySignature(body, header, "secret") {
+ t.Errorf("header %q must be rejected", header)
+ }
+ }
+}
+
+func TestParseUnixSeconds(t *testing.T) {
+ if got := parseUnixSeconds("1716000000"); got.Unix() != 1716000000 {
+ t.Fatalf("unexpected time %v", got)
+ }
+ if got := parseUnixSeconds(""); !got.IsZero() {
+ t.Fatalf("expected a zero time, got %v", got)
+ }
+ if got := parseUnixSeconds("not-a-number"); !got.IsZero() {
+ t.Fatalf("expected a zero time, got %v", got)
+ }
+}
+
+func TestFirstNonEmptyStr(t *testing.T) {
+ if got := firstNonEmptyStr("", "second", "third"); got != "second" {
+ t.Fatalf("unexpected value %q", got)
+ }
+ if got := firstNonEmptyStr("", ""); got != "" {
+ t.Fatalf("expected an empty string, got %q", got)
+ }
+}
+
+// Meta sends the template id as a bare number, which loses precision if decoded as a float.
+func TestStringifyTemplateID(t *testing.T) {
+ tests := []struct {
+ in any
+ want string
+ }{
+ {"12345", "12345"},
+ {json.Number("1234567890123456789"), "1234567890123456789"},
+ {float64(12345), "12345"},
+ {nil, ""},
+ {true, ""},
+ }
+ for _, tc := range tests {
+ if got := stringifyTemplateID(tc.in); got != tc.want {
+ t.Errorf("%v: expected %q, got %q", tc.in, tc.want, got)
+ }
+ }
+}
+
+func messagesFrom(t *testing.T, message string) []ParsedMessage {
+ t.Helper()
+ body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"WABA-1","changes":[{"field":"messages","value":{"metadata":{"phone_number_id":"PN-1"},"messages":[` + message + `]}}]}]}`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ msgs := p.ExtractMessages()
+ if len(msgs) != 1 {
+ t.Fatalf("expected one message, got %d", len(msgs))
+ }
+ return msgs
+}
+
+func wrapValue(t *testing.T, valueJSON string) *WebhookPayload {
+ t.Helper()
+ body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"WABA-1","changes":[{"field":"messages","value":` + valueJSON + `}]}]}`)
+ p, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ return p
+}
diff --git a/internal/whatsapp_template/manager_test.go b/internal/whatsapp_template/manager_test.go
new file mode 100644
index 000000000..f10d2ae93
--- /dev/null
+++ b/internal/whatsapp_template/manager_test.go
@@ -0,0 +1,894 @@
+package whatsapp_template
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/abhinavxd/libredesk/internal/testdb"
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/go-i18n"
+ "github.com/zerodha/logf"
+)
+
+const testInboxName = "wa-test"
+
+var errAccount = &whatsapp.MetaAPIError{Message: "no account"}
+
+type stubResolver struct{}
+
+type failingResolver struct{}
+
+func (stubResolver) WhatsAppAccount(inboxID int) (whatsapp.Account, error) {
+ return whatsapp.Account{PhoneNumberID: "PN1", WABAID: "WABA1", AccessToken: "TOKEN"}, nil
+}
+
+func (failingResolver) WhatsAppAccount(inboxID int) (whatsapp.Account, error) {
+ return whatsapp.Account{}, errAccount
+}
+
+func TestCreateAndFetch(t *testing.T) {
+ m, _ := testManager(t, metaOK("111"))
+ inboxID := seedInbox(t, m)
+
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID,
+ Name: "order_update",
+ Language: "en_US",
+ Category: models.CategoryUtility,
+ BodyContent: "Hi {{1}}",
+ SampleValues: json.RawMessage(`{"1":"Ravi"}`),
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if created.ID == 0 {
+ t.Fatal("expected an id")
+ }
+ // Meta accepted it, so the row carries its template id and waits on review.
+ if created.MetaTemplateID.String != "111" || created.Status != models.StatusPending {
+ t.Fatalf("unexpected created template: %+v", created)
+ }
+ // Empty JSON columns must default rather than land as NULL.
+ if string(created.Buttons) != "[]" || string(created.SampleValues) == "" {
+ t.Fatalf("unexpected json defaults: buttons=%s sample=%s", created.Buttons, created.SampleValues)
+ }
+
+ got, err := m.GetByID(created.ID)
+ if err != nil {
+ t.Fatalf("get by id: %v", err)
+ }
+ if got.Name != "order_update" {
+ t.Fatalf("unexpected template: %+v", got)
+ }
+
+ byName, err := m.GetByName(inboxID, "order_update")
+ if err != nil {
+ t.Fatalf("get by name: %v", err)
+ }
+ if byName.ID != created.ID {
+ t.Fatalf("unexpected template: %+v", byName)
+ }
+
+ list, err := m.GetByInbox(inboxID)
+ if err != nil {
+ t.Fatalf("get by inbox: %v", err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("expected one template, got %d", len(list))
+ }
+}
+
+func TestCreateDuplicateNameAndLanguage(t *testing.T) {
+ m, _ := testManager(t, metaOK("222"))
+ inboxID := seedInbox(t, m)
+ tmpl := models.Template{InboxID: inboxID, Name: "dupe", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi"}
+
+ if _, err := m.Create(context.Background(), tmpl); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ _, err := m.Create(context.Background(), tmpl)
+ if err == nil || !strings.Contains(strings.ToLower(err.Error()), "exists") {
+ t.Fatalf("expected a conflict, got %v", err)
+ }
+}
+
+// The same name in another language is a separate template on Meta, so it must be allowed.
+func TestCreateSameNameDifferentLanguage(t *testing.T) {
+ m, _ := testManager(t, metaOK("333"))
+ inboxID := seedInbox(t, m)
+ base := models.Template{InboxID: inboxID, Name: "greeting", Category: models.CategoryUtility, BodyContent: "Hi"}
+
+ base.Language = "en_US"
+ if _, err := m.Create(context.Background(), base); err != nil {
+ t.Fatalf("en create: %v", err)
+ }
+ base.Language = "mr"
+ if _, err := m.Create(context.Background(), base); err != nil {
+ t.Fatalf("mr create: %v", err)
+ }
+}
+
+func TestCreateMarksRejectedWhenMetaRefuses(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"bad content","code":100,"error_user_msg":"Template violates policy"}}`))
+ })
+ inboxID := seedInbox(t, m)
+
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "rejected_tmpl", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if created.Status != models.StatusRejected || created.RejectionReason.String != "Template violates policy" {
+ t.Fatalf("unexpected template: %+v", created)
+ }
+
+ stored, err := m.GetByID(created.ID)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if stored.Status != models.StatusRejected {
+ t.Fatalf("the rejection must be persisted, got %+v", stored)
+ }
+}
+
+// A template Meta never accepted still needs sample values, so a missing one is a local rejection.
+func TestCreateMarksRejectedWhenSubmissionCannotBeBuilt(t *testing.T) {
+ m, _ := testManager(t, metaOK("444"))
+ inboxID := seedInbox(t, m)
+
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "no_samples", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi {{name}}",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if created.Status != models.StatusRejected || !strings.Contains(created.RejectionReason.String, "could not build") {
+ t.Fatalf("unexpected template: %+v", created)
+ }
+}
+
+func TestCreateWithoutMetaClientStaysPending(t *testing.T) {
+ m, _ := testManager(t, nil)
+ m.client, m.resolver = nil, nil
+ inboxID := seedInbox(t, m)
+
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "offline_tmpl", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if created.Status != models.StatusPending || created.MetaTemplateID.Valid {
+ t.Fatalf("unexpected template: %+v", created)
+ }
+}
+
+func TestCreateWhenAccountCannotBeResolved(t *testing.T) {
+ m, _ := testManager(t, metaOK("555"))
+ m.resolver = failingResolver{}
+ inboxID := seedInbox(t, m)
+
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "no_account", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if created.Status != models.StatusRejected || !strings.Contains(created.RejectionReason.String, "resolve WhatsApp account") {
+ t.Fatalf("unexpected template: %+v", created)
+ }
+}
+
+func TestGetByIDNotFound(t *testing.T) {
+ m, _ := testManager(t, nil)
+ if _, err := m.GetByID(9999999); err == nil {
+ t.Fatal("expected a not-found error")
+ }
+}
+
+func TestGetByNameNotFound(t *testing.T) {
+ m, _ := testManager(t, nil)
+ inboxID := seedInbox(t, m)
+ if _, err := m.GetByName(inboxID, "missing"); err != ErrTemplateNotFound {
+ t.Fatalf("expected ErrTemplateNotFound, got %v", err)
+ }
+}
+
+func TestGetApproved(t *testing.T) {
+ m, _ := testManager(t, metaOK("666"))
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "approval_flow", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ // Pending is not sendable.
+ if _, err := m.GetApproved(inboxID, "approval_flow", "en_US"); err == nil {
+ t.Fatal("expected a not-approved error")
+ }
+ if _, err := m.GetApproved(inboxID, "nope", "en_US"); err != ErrTemplateNotFound {
+ t.Fatalf("expected ErrTemplateNotFound, got %v", err)
+ }
+
+ if err := m.HandleStatusUpdate(inboxID, created.MetaTemplateID.String, "approval_flow", "en_US", "APPROVED", "NONE"); err != nil {
+ t.Fatalf("status update: %v", err)
+ }
+ approved, err := m.GetApproved(inboxID, "approval_flow", "en_US")
+ if err != nil {
+ t.Fatalf("get approved: %v", err)
+ }
+ if approved.Status != models.StatusApproved || approved.RejectionReason.Valid {
+ t.Fatalf("unexpected template: %+v", approved)
+ }
+}
+
+func TestHandleStatusUpdate(t *testing.T) {
+ m, _ := testManager(t, metaOK("777"))
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "status_flow", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ metaID string
+ tmplName string
+ event string
+ reason string
+ wantStatus string
+ wantReason string
+ }{
+ {"rejected by meta id", created.MetaTemplateID.String, "status_flow", "REJECTED", "INVALID_FORMAT", models.StatusRejected, "INVALID_FORMAT"},
+ {"paused", created.MetaTemplateID.String, "status_flow", "PAUSED", "", models.StatusPaused, ""},
+ {"disabled", created.MetaTemplateID.String, "status_flow", "DISABLED", "", models.StatusDisabled, ""},
+ {"reinstated counts as approved", created.MetaTemplateID.String, "status_flow", "REINSTATED", "NONE", models.StatusApproved, ""},
+ {"matched by name when the id is unknown", "does-not-exist", "status_flow", "REJECTED", "SCAM", models.StatusRejected, "SCAM"},
+ {"matched by name when no id is sent", "", "status_flow", "APPROVED", "NONE", models.StatusApproved, ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := m.HandleStatusUpdate(inboxID, tc.metaID, tc.tmplName, "en_US", tc.event, tc.reason); err != nil {
+ t.Fatalf("status update: %v", err)
+ }
+ got, err := m.GetByID(created.ID)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Status != tc.wantStatus {
+ t.Fatalf("expected status %s, got %s", tc.wantStatus, got.Status)
+ }
+ if got.RejectionReason.String != tc.wantReason {
+ t.Fatalf("expected reason %q, got %q", tc.wantReason, got.RejectionReason.String)
+ }
+ })
+ }
+}
+
+func TestHandleStatusUpdateIgnoresUnknownRowsAndEvents(t *testing.T) {
+ m, _ := testManager(t, nil)
+ inboxID := seedInbox(t, m)
+
+ if err := m.HandleStatusUpdate(inboxID, "", "", "en_US", "APPROVED", ""); err == nil {
+ t.Fatal("expected an error when the payload identifies no template")
+ }
+ // An event libredesk does not model must not touch the row or fail the delivery.
+ if err := m.HandleStatusUpdate(inboxID, "1", "any", "en_US", "PENDING_DELETION", ""); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "unknown-id", "", "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "unknown-id", "unknown-name", "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestDelete(t *testing.T) {
+ var deleted []string
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ deleted = append(deleted, r.URL.Query().Get("name"))
+ w.Write([]byte(`{"success":true}`))
+ return
+ }
+ metaOK("888")(w, r)
+ })
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "deletable", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ if err := m.Delete(context.Background(), created.ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if len(deleted) != 1 || deleted[0] != "deletable" {
+ t.Fatalf("expected the template to be deleted on Meta, got %v", deleted)
+ }
+ if _, err := m.GetByID(created.ID); err == nil {
+ t.Fatal("expected the row to be gone")
+ }
+}
+
+// The CSAT template is provisioned by libredesk, so deleting it would break resolved-conversation surveys.
+func TestDeleteRejectsReservedTemplate(t *testing.T) {
+ m, _ := testManager(t, metaOK("999"))
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: models.CSATTemplateName(inboxID), Language: "en_US", Category: models.CategoryUtility, BodyContent: "Rate us",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ err = m.Delete(context.Background(), created.ID)
+ if err == nil || !strings.Contains(err.Error(), "reserved") {
+ t.Fatalf("expected a reserved-template error, got %v", err)
+ }
+ if _, err := m.GetByID(created.ID); err != nil {
+ t.Fatalf("the row must survive: %v", err)
+ }
+}
+
+// Meta failing the delete must not leave the row behind in libredesk.
+func TestDeleteContinuesWhenMetaFails(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"gone","code":100}}`))
+ return
+ }
+ metaOK("1000")(w, r)
+ })
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "stale", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := m.Delete(context.Background(), created.ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := m.GetByID(created.ID); err == nil {
+ t.Fatal("expected the row to be gone")
+ }
+}
+
+func TestDeleteNotFound(t *testing.T) {
+ m, _ := testManager(t, nil)
+ if err := m.Delete(context.Background(), 9999999); err == nil {
+ t.Fatal("expected a not-found error")
+ }
+}
+
+func TestSyncFromMeta(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, map[string]any{"data": []map[string]any{
+ {
+ "id": "SYNC1", "name": "synced_one", "language": "en_US", "category": "MARKETING", "status": "APPROVED",
+ "components": []map[string]any{
+ {"type": "HEADER", "format": "IMAGE"},
+ {"type": "BODY", "text": "Offer for {{1}}"},
+ {"type": "FOOTER", "text": "Reply STOP to opt out"},
+ {"type": "BUTTONS", "buttons": []map[string]any{{"type": "QUICK_REPLY", "text": "Tell me more"}}},
+ },
+ },
+ {"id": "SYNC2", "name": "synced_two", "language": "mr", "category": "UTILITY", "status": "PENDING"},
+ }})
+ })
+ inboxID := seedInbox(t, m)
+
+ count, err := m.SyncFromMeta(context.Background(), inboxID)
+ if err != nil {
+ t.Fatalf("sync: %v", err)
+ }
+ if count != 2 {
+ t.Fatalf("expected two templates, got %d", count)
+ }
+ got, err := m.GetByName(inboxID, "synced_one")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.HeaderType.String != "IMAGE" || got.BodyContent != "Offer for {{1}}" || got.FooterContent.String != "Reply STOP to opt out" {
+ t.Fatalf("unexpected synced template: %+v", got)
+ }
+
+ // Syncing again must update rather than duplicate.
+ if _, err := m.SyncFromMeta(context.Background(), inboxID); err != nil {
+ t.Fatalf("second sync: %v", err)
+ }
+ list, err := m.GetByInbox(inboxID)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(list) != 2 {
+ t.Fatalf("expected two rows after a repeat sync, got %d", len(list))
+ }
+}
+
+// Meta is the source of truth, so a status change there overwrites the local one.
+func TestSyncFromMetaOverwritesLocalStatus(t *testing.T) {
+ status := "APPROVED"
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ metaOK("SYNC3")(w, r)
+ return
+ }
+ writeJSON(w, map[string]any{"data": []map[string]any{
+ {"id": "SYNC3", "name": "drifting", "language": "en_US", "category": "UTILITY", "status": status,
+ "components": []map[string]any{{"type": "BODY", "text": "Hi"}}},
+ }})
+ })
+ inboxID := seedInbox(t, m)
+ created, err := m.Create(context.Background(), models.Template{
+ InboxID: inboxID, Name: "drifting", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi",
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ if _, err := m.SyncFromMeta(context.Background(), inboxID); err != nil {
+ t.Fatalf("sync: %v", err)
+ }
+ if got, _ := m.GetByID(created.ID); got.Status != models.StatusApproved {
+ t.Fatalf("expected APPROVED after the sync, got %s", got.Status)
+ }
+
+ status = "PAUSED"
+ if _, err := m.SyncFromMeta(context.Background(), inboxID); err != nil {
+ t.Fatalf("sync: %v", err)
+ }
+ if got, _ := m.GetByID(created.ID); got.Status != models.StatusPaused {
+ t.Fatalf("expected PAUSED after the sync, got %s", got.Status)
+ }
+}
+
+func TestSyncFromMetaFailures(t *testing.T) {
+ t.Run("no client", func(t *testing.T) {
+ m, _ := testManager(t, nil)
+ m.client = nil
+ if _, err := m.SyncFromMeta(context.Background(), 1); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("account cannot be resolved", func(t *testing.T) {
+ m, _ := testManager(t, metaOK("x"))
+ m.resolver = failingResolver{}
+ if _, err := m.SyncFromMeta(context.Background(), 1); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("meta rejects the fetch", func(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(401)
+ w.Write([]byte(`{"error":{"message":"bad token","code":190}}`))
+ })
+ if _, err := m.SyncFromMeta(context.Background(), seedInbox(t, m)); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+
+ t.Run("a row that cannot be stored is skipped", func(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, map[string]any{"data": []map[string]any{
+ {"id": "SYNC4", "name": strings.Repeat("x", 600), "language": "en_US", "category": "UTILITY", "status": "APPROVED"},
+ {"id": "SYNC5", "name": "fine", "language": "en_US", "category": "UTILITY", "status": "APPROVED"},
+ }})
+ })
+ count, err := m.SyncFromMeta(context.Background(), seedInbox(t, m))
+ if err != nil {
+ t.Fatalf("sync: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("expected the oversized row to be skipped, got count %d", count)
+ }
+ })
+}
+
+func TestEnsureReservedCreatesThenEdits(t *testing.T) {
+ var (
+ submitted []map[string]any
+ edited []string
+ )
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/message_templates") && r.Method == http.MethodPost {
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+ submitted = append(submitted, body)
+ writeJSON(w, map[string]any{"id": "CSAT1", "status": "PENDING"})
+ return
+ }
+ if r.Method == http.MethodPost {
+ edited = append(edited, r.URL.Path)
+ writeJSON(w, map[string]bool{"success": true})
+ return
+ }
+ writeJSON(w, map[string]any{"data": []any{}})
+ })
+ inboxID := seedInbox(t, m)
+ name := models.CSATTemplateName(inboxID)
+
+ desired := models.Template{
+ InboxID: inboxID,
+ Name: name,
+ Language: "en_US",
+ Category: models.CategoryUtility,
+ BodyContent: "Rate us please",
+ Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("first ensure: %v", err)
+ }
+ if len(submitted) != 1 {
+ t.Fatalf("expected one submission, got %d", len(submitted))
+ }
+
+ // Identical content must not go back to Meta.
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("second ensure: %v", err)
+ }
+ if len(submitted) != 1 || len(edited) != 0 {
+ t.Fatalf("unchanged content must not be resubmitted: submits=%d edits=%d", len(submitted), len(edited))
+ }
+
+ // While the template is pending review Meta refuses edits, so libredesk must hold them back.
+ changed := desired
+ changed.BodyContent = "Rate us, please"
+ if err := m.EnsureReserved(context.Background(), changed); err != nil {
+ t.Fatalf("pending ensure: %v", err)
+ }
+ if len(edited) != 0 {
+ t.Fatalf("expected no edit while pending, got %v", edited)
+ }
+
+ if err := m.HandleStatusUpdate(inboxID, "CSAT1", name, "en_US", "APPROVED", "NONE"); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ if err := m.EnsureReserved(context.Background(), changed); err != nil {
+ t.Fatalf("approved ensure: %v", err)
+ }
+ if len(edited) != 1 || !strings.HasSuffix(edited[0], "/CSAT1") {
+ t.Fatalf("expected an edit against the Meta template id, got %v", edited)
+ }
+ stored, err := m.GetByName(inboxID, name)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if stored.BodyContent != "Rate us, please" || stored.Status != models.StatusPending {
+ t.Fatalf("an edit must store the new copy and go back to pending: %+v", stored)
+ }
+}
+
+// A language change is a different template on Meta, so it has to be created rather than edited.
+func TestEnsureReservedCreatesPerLanguage(t *testing.T) {
+ submits := 0
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ submits++
+ writeJSON(w, map[string]any{"id": "CSAT" + string(rune('A'+submits)), "status": "PENDING"})
+ })
+ inboxID := seedInbox(t, m)
+ desired := models.Template{
+ InboxID: inboxID, Name: models.CSATTemplateName(inboxID), Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+
+ desired.Language = "en_US"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("en ensure: %v", err)
+ }
+ desired.Language = "mr"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("mr ensure: %v", err)
+ }
+ if submits != 2 {
+ t.Fatalf("expected a submission per language, got %d", submits)
+ }
+}
+
+// A template that was rejected before it reached Meta has no id to edit, so it is submitted afresh.
+func TestEnsureReservedResubmitsWhenMetaIDIsMissing(t *testing.T) {
+ submits := 0
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ submits++
+ if submits == 1 {
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"nope","code":100}}`))
+ return
+ }
+ writeJSON(w, map[string]any{"id": "CSAT9", "status": "PENDING"})
+ })
+ inboxID := seedInbox(t, m)
+ desired := models.Template{
+ InboxID: inboxID, Name: models.CSATTemplateName(inboxID), Language: "en_US", Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("first ensure: %v", err)
+ }
+ stored, _ := m.GetByName(inboxID, desired.Name)
+ if stored.Status != models.StatusRejected || stored.MetaTemplateID.Valid {
+ t.Fatalf("expected a rejected row with no meta id: %+v", stored)
+ }
+
+ desired.BodyContent = "Rate us now"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("second ensure: %v", err)
+ }
+ stored, _ = m.GetByName(inboxID, desired.Name)
+ if stored.MetaTemplateID.String != "CSAT9" || stored.Status != models.StatusPending {
+ t.Fatalf("expected a fresh submission: %+v", stored)
+ }
+}
+
+func TestEnsureReservedEditFailures(t *testing.T) {
+ t.Run("meta rejects the edit", func(t *testing.T) {
+ m, _ := testManager(t, func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/message_templates") {
+ writeJSON(w, map[string]any{"id": "CSATE", "status": "PENDING"})
+ return
+ }
+ w.WriteHeader(400)
+ w.Write([]byte(`{"error":{"message":"cannot edit","code":100,"error_user_msg":"Edit refused"}}`))
+ })
+ inboxID := seedInbox(t, m)
+ name := models.CSATTemplateName(inboxID)
+ desired := models.Template{
+ InboxID: inboxID, Name: name, Language: "en_US", Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "CSATE", name, "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ desired.BodyContent = "Rate us again"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("ensure: %v", err)
+ }
+ stored, _ := m.GetByName(inboxID, name)
+ if stored.Status != models.StatusRejected || stored.RejectionReason.String != "Edit refused" {
+ t.Fatalf("expected the refusal to be recorded: %+v", stored)
+ }
+ })
+
+ t.Run("account cannot be resolved", func(t *testing.T) {
+ m, _ := testManager(t, metaOK("CSATF"))
+ inboxID := seedInbox(t, m)
+ name := models.CSATTemplateName(inboxID)
+ desired := models.Template{
+ InboxID: inboxID, Name: name, Language: "en_US", Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "CSATF", name, "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ m.resolver = failingResolver{}
+ desired.BodyContent = "Rate us again"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("ensure: %v", err)
+ }
+ stored, _ := m.GetByName(inboxID, name)
+ if stored.Status != models.StatusRejected {
+ t.Fatalf("expected a rejected row: %+v", stored)
+ }
+ })
+
+ t.Run("submission cannot be built", func(t *testing.T) {
+ m, _ := testManager(t, metaOK("CSATG"))
+ inboxID := seedInbox(t, m)
+ name := models.CSATTemplateName(inboxID)
+ desired := models.Template{
+ InboxID: inboxID, Name: name, Language: "en_US", Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "CSATG", name, "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ // A body placeholder with no sample value cannot be submitted.
+ desired.BodyContent = "Rate us {{name}}"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("ensure: %v", err)
+ }
+ stored, _ := m.GetByName(inboxID, name)
+ if stored.Status != models.StatusRejected || !strings.Contains(stored.RejectionReason.String, "could not build") {
+ t.Fatalf("expected a build failure to be recorded: %+v", stored)
+ }
+ })
+
+ t.Run("without a meta client the row is still updated", func(t *testing.T) {
+ m, _ := testManager(t, metaOK("CSATH"))
+ inboxID := seedInbox(t, m)
+ name := models.CSATTemplateName(inboxID)
+ desired := models.Template{
+ InboxID: inboxID, Name: name, Language: "en_US", Category: models.CategoryUtility,
+ BodyContent: "Rate us", Buttons: csatButtons("Rate us", "https://desk.test/csat/{{1}}"),
+ }
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := m.HandleStatusUpdate(inboxID, "CSATH", name, "en_US", "APPROVED", ""); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ m.client = nil
+ desired.BodyContent = "Rate us offline"
+ if err := m.EnsureReserved(context.Background(), desired); err != nil {
+ t.Fatalf("ensure: %v", err)
+ }
+ stored, _ := m.GetByName(inboxID, name)
+ if stored.BodyContent != "Rate us offline" {
+ t.Fatalf("expected the local copy to be updated: %+v", stored)
+ }
+ })
+}
+
+// Every method has to surface a database failure instead of pretending the write happened.
+func TestDatabaseFailuresSurface(t *testing.T) {
+ m := managerOnClosedDB(t)
+
+ if _, err := m.GetByInbox(1); err == nil {
+ t.Error("GetByInbox must fail")
+ }
+ if _, err := m.GetByID(1); err == nil {
+ t.Error("GetByID must fail")
+ }
+ if _, err := m.GetByName(1, "x"); err == nil {
+ t.Error("GetByName must fail")
+ }
+ if _, err := m.GetApproved(1, "x", "en_US"); err == nil {
+ t.Error("GetApproved must fail")
+ }
+ if _, err := m.Create(context.Background(), models.Template{InboxID: 1, Name: "x", Language: "en_US", Category: models.CategoryUtility, BodyContent: "Hi"}); err == nil {
+ t.Error("Create must fail")
+ }
+ if err := m.EnsureReserved(context.Background(), models.Template{InboxID: 1, Name: "x", Language: "en_US", BodyContent: "Hi"}); err == nil {
+ t.Error("EnsureReserved must fail")
+ }
+ if err := m.Delete(context.Background(), 1); err == nil {
+ t.Error("Delete must fail")
+ }
+ if err := m.HandleStatusUpdate(1, "id", "name", "en_US", "APPROVED", ""); err == nil {
+ t.Error("HandleStatusUpdate by meta id must fail")
+ }
+ if err := m.HandleStatusUpdate(1, "", "name", "en_US", "APPROVED", ""); err == nil {
+ t.Error("HandleStatusUpdate by name must fail")
+ }
+ if _, err := m.SyncFromMeta(context.Background(), 1); err != nil {
+ t.Errorf("a sync whose rows cannot be stored still reports what it fetched: %v", err)
+ }
+}
+
+func TestSubmitErrReason(t *testing.T) {
+ if got := submitErrReason(nil); got != "" {
+ t.Fatalf("expected an empty reason, got %q", got)
+ }
+ if got := submitErrReason(&whatsapp.MetaAPIError{Message: "raw", UserMsg: "friendly"}); got != "friendly" {
+ t.Fatalf("expected the user message, got %q", got)
+ }
+ if got := submitErrReason(&whatsapp.MetaAPIError{Message: "raw"}); got != "raw" {
+ t.Fatalf("expected the raw message, got %q", got)
+ }
+ if got := submitErrReason(context.Canceled); got != context.Canceled.Error() {
+ t.Fatalf("unexpected reason %q", got)
+ }
+}
+
+func TestButtonsSurfaceEqualHandlesMalformedJSON(t *testing.T) {
+ if !buttonsSurfaceEqual(json.RawMessage(`not json`), json.RawMessage(`also not json`)) {
+ t.Fatal("two unreadable button sets must compare equal so a reserved template is not resubmitted forever")
+ }
+ if buttonsSurfaceEqual(json.RawMessage(`not json`), csatButtons("Rate us", "https://x.test/{{1}}")) {
+ t.Fatal("an unreadable set must not match a real one")
+ }
+}
+
+func testManager(t *testing.T, handler http.HandlerFunc) (*Manager, *sqlx.DB) {
+ t.Helper()
+ db := testdb.New(t, testInboxName)
+
+ var client *whatsapp.Client
+ if handler != nil {
+ srv := httptest.NewServer(handler)
+ t.Cleanup(srv.Close)
+ client = whatsapp.New(testLogger())
+ client.SetBaseURL(srv.URL)
+ } else {
+ client = whatsapp.New(testLogger())
+ }
+
+ m, err := New(Opts{Lo: testLogger(), DB: db, I18n: testI18n(t), Client: client, Resolver: stubResolver{}})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return m, db
+}
+
+func seedInbox(t *testing.T, m *Manager) int {
+ t.Helper()
+ var id int
+ db := testdb.New(t, testInboxName)
+ if err := db.QueryRow(`INSERT INTO inboxes (channel, config, "name", enabled) VALUES ('whatsapp', '{}'::jsonb, $1, true) RETURNING id`,
+ "wa-"+t.Name()).Scan(&id); err != nil {
+ t.Fatalf("seeding an inbox: %v", err)
+ }
+ return id
+}
+
+func csatButtons(text, url string) json.RawMessage {
+ raw, _ := json.Marshal([]map[string]any{{"type": "URL", "text": text, "url": url, "example": []string{strings.ReplaceAll(url, "{{1}}", "example")}}})
+ return raw
+}
+
+func metaOK(templateID string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, map[string]any{"id": templateID, "status": "PENDING", "category": "UTILITY"})
+ }
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+}
+
+func testLogger() *logf.Logger {
+ l := logf.New(logf.Opts{Level: logf.FatalLevel})
+ return &l
+}
+
+func testI18n(t *testing.T) *i18n.I18n {
+ t.Helper()
+ i, err := i18n.New([]byte(`{"_.code":"en","_.name":"English","globals.messages.somethingWentWrong":"Something went wrong","globals.messages.notFound":"Not found","globals.messages.errorAlreadyExists":"Already exists"}`))
+ if err != nil {
+ t.Fatalf("i18n: %v", err)
+ }
+ return i
+}
+
+// managerOnClosedDB builds a manager whose statements are prepared and then invalidated.
+func managerOnClosedDB(t *testing.T) *Manager {
+ t.Helper()
+ testdb.New(t, testInboxName)
+ db, err := sqlx.Connect("postgres", strings.Replace(os.Getenv("LIBREDESK_TEST_DB_DSN"), "/libredesk?", "/libredesk_test_"+testInboxName+"?", 1))
+ if err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ srv := httptest.NewServer(metaOK("CLOSED"))
+ t.Cleanup(srv.Close)
+ client := whatsapp.New(testLogger())
+ client.SetBaseURL(srv.URL)
+
+ m, err := New(Opts{Lo: testLogger(), DB: db, I18n: testI18n(t), Client: client, Resolver: stubResolver{}})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+ return m
+}
diff --git a/internal/whatsapp_template/models/models.go b/internal/whatsapp_template/models/models.go
new file mode 100644
index 000000000..d717cee37
--- /dev/null
+++ b/internal/whatsapp_template/models/models.go
@@ -0,0 +1,55 @@
+package models
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/volatiletech/null/v9"
+)
+
+// Reserved per-inbox CSAT template, auto-provisioned and hidden from the agent picker.
+const CSATTemplateNamePrefix = "libredesk_csat_"
+
+func CSATTemplateName(inboxID int) string {
+ return fmt.Sprintf("%s%d", CSATTemplateNamePrefix, inboxID)
+}
+
+// Status values mirror Meta's template lifecycle.
+const (
+ StatusPending = "PENDING"
+ StatusApproved = "APPROVED"
+ StatusRejected = "REJECTED"
+ StatusPendingDeletion = "PENDING_DELETION"
+ StatusDisabled = "DISABLED"
+ StatusPaused = "PAUSED"
+ StatusInAppeal = "IN_APPEAL"
+ StatusPendingQualityReview = "PENDING_QUALITY_REVIEW"
+)
+
+// Category values supported on Meta.
+const (
+ CategoryMarketing = "MARKETING"
+ CategoryUtility = "UTILITY"
+ CategoryAuthentication = "AUTHENTICATION"
+)
+
+// Template mirrors Meta's record plus libredesk-side scoping (inbox_id) and submission state.
+type Template struct {
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ InboxID int `db:"inbox_id" json:"inbox_id"`
+ MetaTemplateID null.String `db:"meta_template_id" json:"meta_template_id"`
+ Name string `db:"name" json:"name"`
+ Language string `db:"language" json:"language"`
+ Category string `db:"category" json:"category"`
+ Status string `db:"status" json:"status"`
+ HeaderType null.String `db:"header_type" json:"header_type"`
+ HeaderContent null.String `db:"header_content" json:"header_content"`
+ BodyContent string `db:"body_content" json:"body_content"`
+ FooterContent null.String `db:"footer_content" json:"footer_content"`
+ Buttons json.RawMessage `db:"buttons" json:"buttons"`
+ SampleValues json.RawMessage `db:"sample_values" json:"sample_values"`
+ RejectionReason null.String `db:"rejection_reason" json:"rejection_reason"`
+}
diff --git a/internal/whatsapp_template/models/models_test.go b/internal/whatsapp_template/models/models_test.go
new file mode 100644
index 000000000..1f663bbbe
--- /dev/null
+++ b/internal/whatsapp_template/models/models_test.go
@@ -0,0 +1,20 @@
+package models
+
+import (
+ "strings"
+ "testing"
+)
+
+// The reserved name is how the CSAT template is found, and how the delete guard recognises it.
+func TestCSATTemplateName(t *testing.T) {
+ name := CSATTemplateName(15)
+ if name != "libredesk_csat_15" {
+ t.Fatalf("unexpected name %q", name)
+ }
+ if !strings.HasPrefix(name, CSATTemplateNamePrefix) {
+ t.Fatalf("%q must carry the reserved prefix", name)
+ }
+ if CSATTemplateName(15) == CSATTemplateName(16) {
+ t.Fatal("each inbox needs its own template name")
+ }
+}
diff --git a/internal/whatsapp_template/queries.sql b/internal/whatsapp_template/queries.sql
new file mode 100644
index 000000000..8c4a48b07
--- /dev/null
+++ b/internal/whatsapp_template/queries.sql
@@ -0,0 +1,116 @@
+-- name: insert
+INSERT INTO whatsapp_templates (
+ inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content,
+ buttons, sample_values, rejection_reason
+)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+RETURNING *;
+
+-- name: update
+UPDATE whatsapp_templates
+SET name = $2,
+ language = $3,
+ category = $4,
+ header_type = $5,
+ header_content = $6,
+ body_content = $7,
+ footer_content = $8,
+ buttons = $9,
+ sample_values = $10,
+ updated_at = NOW()
+WHERE id = $1
+RETURNING *;
+
+-- name: update-status
+UPDATE whatsapp_templates
+SET status = $2,
+ rejection_reason = NULLIF($3, ''),
+ updated_at = NOW()
+WHERE id = $1
+RETURNING *;
+
+-- name: update-meta-id
+UPDATE whatsapp_templates
+SET meta_template_id = $2,
+ status = $3,
+ updated_at = NOW()
+WHERE id = $1;
+
+-- name: delete
+DELETE FROM whatsapp_templates WHERE id = $1;
+
+-- name: delete-missing-from-meta
+-- Rows without a Meta template ID are local drafts that never reached Meta and must survive the prune.
+DELETE FROM whatsapp_templates
+WHERE inbox_id = $1
+ AND meta_template_id IS NOT NULL
+ AND meta_template_id != ''
+ AND NOT (meta_template_id = ANY($2::text[]));
+
+-- name: get-by-id
+SELECT id, created_at, updated_at, inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content, buttons, sample_values, rejection_reason
+FROM whatsapp_templates WHERE id = $1;
+
+-- name: get-by-inbox
+SELECT id, created_at, updated_at, inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content, buttons, sample_values, rejection_reason
+FROM whatsapp_templates WHERE inbox_id = $1 ORDER BY updated_at DESC;
+
+-- name: get-by-name-language
+SELECT id, created_at, updated_at, inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content, buttons, sample_values, rejection_reason
+FROM whatsapp_templates WHERE inbox_id = $1 AND name = $2 AND language = $3;
+
+-- name: get-by-name
+SELECT id, created_at, updated_at, inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content, buttons, sample_values, rejection_reason
+FROM whatsapp_templates WHERE inbox_id = $1 AND name = $2 LIMIT 1;
+
+-- name: upsert-from-meta
+INSERT INTO whatsapp_templates (
+ inbox_id, meta_template_id, name, language, category, status,
+ header_type, header_content, body_content, footer_content,
+ buttons, sample_values, rejection_reason
+)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ON CONFLICT (inbox_id, name, language) DO UPDATE SET
+ meta_template_id = EXCLUDED.meta_template_id,
+ category = EXCLUDED.category,
+ status = EXCLUDED.status,
+ header_type = EXCLUDED.header_type,
+ header_content = EXCLUDED.header_content,
+ body_content = EXCLUDED.body_content,
+ footer_content = EXCLUDED.footer_content,
+ buttons = EXCLUDED.buttons,
+ rejection_reason = EXCLUDED.rejection_reason,
+ updated_at = CASE WHEN (
+ whatsapp_templates.meta_template_id IS DISTINCT FROM EXCLUDED.meta_template_id OR
+ whatsapp_templates.category IS DISTINCT FROM EXCLUDED.category OR
+ whatsapp_templates.status IS DISTINCT FROM EXCLUDED.status OR
+ whatsapp_templates.header_type IS DISTINCT FROM EXCLUDED.header_type OR
+ whatsapp_templates.header_content IS DISTINCT FROM EXCLUDED.header_content OR
+ whatsapp_templates.body_content IS DISTINCT FROM EXCLUDED.body_content OR
+ whatsapp_templates.footer_content IS DISTINCT FROM EXCLUDED.footer_content OR
+ whatsapp_templates.buttons IS DISTINCT FROM EXCLUDED.buttons OR
+ whatsapp_templates.rejection_reason IS DISTINCT FROM EXCLUDED.rejection_reason
+ ) THEN NOW() ELSE whatsapp_templates.updated_at END
+RETURNING *;
+
+-- name: update-status-by-meta-id
+UPDATE whatsapp_templates
+SET status = $3,
+ rejection_reason = NULLIF($4, ''),
+ updated_at = NOW()
+WHERE inbox_id = $1
+ AND meta_template_id = $2;
+
+-- name: update-status-by-name-language
+UPDATE whatsapp_templates
+SET status = $3,
+ rejection_reason = NULLIF($4, ''),
+ updated_at = NOW()
+WHERE inbox_id = $1
+ AND name = $2
+ AND language = $5;
diff --git a/internal/whatsapp_template/template.go b/internal/whatsapp_template/template.go
new file mode 100644
index 000000000..84dbd3de8
--- /dev/null
+++ b/internal/whatsapp_template/template.go
@@ -0,0 +1,639 @@
+// Package whatsapp_template manages WhatsApp templates stored locally and mirrored against Meta.
+package whatsapp_template
+
+import (
+ "cmp"
+ "context"
+ "database/sql"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/abhinavxd/libredesk/internal/dbutil"
+ "github.com/abhinavxd/libredesk/internal/envelope"
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/go-i18n"
+ "github.com/lib/pq"
+ "github.com/volatiletech/null/v9"
+ "github.com/zerodha/logf"
+)
+
+var (
+ //go:embed queries.sql
+ efs embed.FS
+
+ ErrTemplateNotFound = errors.New("whatsapp template not found")
+)
+
+type AccountResolver interface {
+ WhatsAppAccount(inboxID int) (whatsapp.Account, error)
+}
+
+type Manager struct {
+ q queries
+ lo *logf.Logger
+ i18n *i18n.I18n
+ client *whatsapp.Client
+ resolver AccountResolver
+}
+
+type queries struct {
+ Insert *sqlx.Stmt `query:"insert"`
+ Update *sqlx.Stmt `query:"update"`
+ UpdateStatus *sqlx.Stmt `query:"update-status"`
+ UpdateMetaID *sqlx.Stmt `query:"update-meta-id"`
+ Delete *sqlx.Stmt `query:"delete"`
+ DeleteMissingFromMeta *sqlx.Stmt `query:"delete-missing-from-meta"`
+ GetByID *sqlx.Stmt `query:"get-by-id"`
+ GetByInbox *sqlx.Stmt `query:"get-by-inbox"`
+ GetByName *sqlx.Stmt `query:"get-by-name"`
+ GetByNameLanguage *sqlx.Stmt `query:"get-by-name-language"`
+ UpsertFromMeta *sqlx.Stmt `query:"upsert-from-meta"`
+ UpdateStatusByMetaID *sqlx.Stmt `query:"update-status-by-meta-id"`
+ UpdateStatusByNameLanguage *sqlx.Stmt `query:"update-status-by-name-language"`
+}
+
+type Opts struct {
+ Lo *logf.Logger
+ DB *sqlx.DB
+ I18n *i18n.I18n
+ Client *whatsapp.Client
+ Resolver AccountResolver
+}
+
+func New(opts Opts) (*Manager, error) {
+ var q queries
+ if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
+ return nil, err
+ }
+ return &Manager{
+ q: q,
+ lo: opts.Lo,
+ i18n: opts.I18n,
+ client: opts.Client,
+ resolver: opts.Resolver,
+ }, nil
+}
+
+func (m *Manager) GetByInbox(inboxID int) ([]models.Template, error) {
+ out := make([]models.Template, 0)
+ if err := m.q.GetByInbox.Select(&out, inboxID); err != nil {
+ m.lo.Error("error fetching whatsapp templates", "inbox_id", inboxID, "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return out, nil
+}
+
+func (m *Manager) GetByID(id int) (models.Template, error) {
+ var t models.Template
+ if err := m.q.GetByID.Get(&t, id); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return t, envelope.NewError(envelope.NotFoundError, m.i18n.T("globals.messages.notFound"), nil)
+ }
+ m.lo.Error("error fetching whatsapp template", "id", id, "error", err)
+ return t, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return t, nil
+}
+
+// GetByName returns the template matching inbox + name regardless of status.
+func (m *Manager) GetByName(inboxID int, name string) (models.Template, error) {
+ var t models.Template
+ if err := m.q.GetByName.Get(&t, inboxID, name); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return t, ErrTemplateNotFound
+ }
+ return t, err
+ }
+ return t, nil
+}
+
+func (m *Manager) GetApproved(inboxID int, name, language string) (models.Template, error) {
+ var t models.Template
+ if err := m.q.GetByNameLanguage.Get(&t, inboxID, name, language); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return t, ErrTemplateNotFound
+ }
+ return t, err
+ }
+ if !strings.EqualFold(t.Status, models.StatusApproved) {
+ return t, fmt.Errorf("template %q (%s) is not approved (status: %s)", name, language, t.Status)
+ }
+ return t, nil
+}
+
+// Create stores a template locally, submits it to Meta and records the returned template id.
+func (m *Manager) Create(ctx context.Context, t models.Template) (models.Template, error) {
+ t.Status = cmp.Or(t.Status, models.StatusPending)
+ if t.Buttons == nil {
+ t.Buttons = json.RawMessage(`[]`)
+ }
+ if t.SampleValues == nil {
+ t.SampleValues = json.RawMessage(`{}`)
+ }
+
+ var stored models.Template
+ if err := m.q.Insert.Get(&stored,
+ t.InboxID, t.MetaTemplateID, t.Name, t.Language, t.Category, t.Status,
+ t.HeaderType, t.HeaderContent, t.BodyContent, t.FooterContent,
+ t.Buttons, t.SampleValues, t.RejectionReason,
+ ); err != nil {
+ m.lo.Error("error inserting whatsapp template", "error", err)
+ if dbutil.IsUniqueViolationError(err) {
+ return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
+ }
+ return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+
+ return m.submitNewToMeta(ctx, stored), nil
+}
+
+// EnsureReserved reconciles a fixed-name template such as the per-inbox CSAT one; a language change creates a new one since Meta keys templates by name+language.
+func (m *Manager) EnsureReserved(ctx context.Context, desired models.Template) error {
+ var existing models.Template
+ err := m.q.GetByNameLanguage.Get(&existing, desired.InboxID, desired.Name, desired.Language)
+ if errors.Is(err, sql.ErrNoRows) {
+ _, cErr := m.Create(ctx, desired)
+ return cErr
+ }
+ if err != nil {
+ m.lo.Error("error loading reserved whatsapp template", "inbox_id", desired.InboxID, "name", desired.Name, "error", err)
+ return err
+ }
+ if !reservedContentChanged(existing, desired) {
+ m.lo.Debug("reserved template already matches desired content", "id", existing.ID, "name", existing.Name)
+ return nil
+ }
+ // Meta only allows editing a template in approved/rejected/paused state; a pending one reconciles on the next save.
+ if strings.EqualFold(existing.Status, models.StatusPending) {
+ m.lo.Warn("skipping reserved template edit while pending meta review", "id", existing.ID, "name", existing.Name)
+ return nil
+ }
+ return m.editReserved(ctx, existing, desired)
+}
+
+// submitNewToMeta submits a freshly stored template to Meta and records the returned id or the rejection reason.
+func (m *Manager) submitNewToMeta(ctx context.Context, stored models.Template) models.Template {
+ if m.client == nil || m.resolver == nil {
+ return stored
+ }
+ acc, err := m.resolver.WhatsAppAccount(stored.InboxID)
+ if err != nil {
+ m.lo.Error("error resolving whatsapp account for template submit", "inbox_id", stored.InboxID, "error", err)
+ return m.markRejected(stored, "could not resolve WhatsApp account for submission")
+ }
+ submission, err := buildSubmission(stored)
+ if err != nil {
+ m.lo.Error("error building template submission", "id", stored.ID, "error", err)
+ return m.markRejected(stored, "could not build template submission: "+err.Error())
+ }
+ metaID, submitErr := m.client.SubmitTemplate(ctx, acc, submission)
+ if submitErr != nil {
+ m.lo.Error("error submitting template to meta", "id", stored.ID, "error", submitErr)
+ return m.markRejected(stored, submitErrReason(submitErr))
+ }
+ if _, err := m.q.UpdateMetaID.Exec(stored.ID, metaID, models.StatusPending); err != nil {
+ m.lo.Error("error persisting meta template id", "id", stored.ID, "error", err)
+ }
+ stored.MetaTemplateID = null.StringFrom(metaID)
+ stored.Status = models.StatusPending
+ return stored
+}
+
+// editReserved persists new content for an existing template and pushes it to Meta in place, re-submitting fresh when it was never registered.
+func (m *Manager) editReserved(ctx context.Context, existing, desired models.Template) error {
+ updated, err := m.updateContent(existing.ID, desired)
+ if err != nil {
+ return err
+ }
+ if m.client == nil || m.resolver == nil {
+ return nil
+ }
+ if !existing.MetaTemplateID.Valid || existing.MetaTemplateID.String == "" {
+ m.submitNewToMeta(ctx, updated)
+ return nil
+ }
+ acc, err := m.resolver.WhatsAppAccount(updated.InboxID)
+ if err != nil {
+ m.lo.Error("error resolving whatsapp account for template edit", "inbox_id", updated.InboxID, "error", err)
+ m.markRejected(updated, "could not resolve WhatsApp account for submission")
+ return nil
+ }
+ edit, err := buildEdit(updated)
+ if err != nil {
+ m.lo.Error("error building template edit", "id", updated.ID, "error", err)
+ m.markRejected(updated, "could not build template edit: "+err.Error())
+ return nil
+ }
+ if err := m.client.EditTemplate(ctx, acc, existing.MetaTemplateID.String, edit); err != nil {
+ m.lo.Error("error editing template on meta", "id", updated.ID, "error", err)
+ m.markRejected(updated, submitErrReason(err))
+ return nil
+ }
+ if _, err := m.q.UpdateStatus.Exec(updated.ID, models.StatusPending, ""); err != nil {
+ m.lo.Error("error persisting template pending status", "id", updated.ID, "error", err)
+ }
+ return nil
+}
+
+func (m *Manager) updateContent(id int, t models.Template) (models.Template, error) {
+ buttons := t.Buttons
+ if buttons == nil {
+ buttons = json.RawMessage(`[]`)
+ }
+ sample := t.SampleValues
+ if sample == nil {
+ sample = json.RawMessage(`{}`)
+ }
+ var updated models.Template
+ if err := m.q.Update.Get(&updated,
+ id, t.Name, t.Language, t.Category, t.HeaderType, t.HeaderContent,
+ t.BodyContent, t.FooterContent, buttons, sample,
+ ); err != nil {
+ m.lo.Error("error updating whatsapp template", "id", id, "error", err)
+ return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return updated, nil
+}
+
+func (m *Manager) markRejected(t models.Template, reason string) models.Template {
+ if _, err := m.q.UpdateStatus.Exec(t.ID, models.StatusRejected, reason); err != nil {
+ m.lo.Error("error persisting template rejected status", "id", t.ID, "error", err)
+ }
+ t.Status = models.StatusRejected
+ t.RejectionReason = null.StringFrom(reason)
+ return t
+}
+
+// Delete removes the template locally and on Meta (best-effort).
+func (m *Manager) Delete(ctx context.Context, id int) error {
+ t, err := m.GetByID(id)
+ if err != nil {
+ return err
+ }
+ if strings.HasPrefix(t.Name, models.CSATTemplateNamePrefix) {
+ return envelope.NewError(envelope.InputError, m.i18n.T("admin.whatsappTemplates.error.reserved"), nil)
+ }
+ // Without a Meta template ID nothing was registered; deleting by name alone would take out every language variant sharing it.
+ if m.client != nil && m.resolver != nil && t.MetaTemplateID.Valid && t.MetaTemplateID.String != "" {
+ if acc, err := m.resolver.WhatsAppAccount(t.InboxID); err == nil {
+ if err := m.client.DeleteTemplate(ctx, acc, t.Name, t.MetaTemplateID.String); err != nil {
+ m.lo.Error("error deleting template on meta", "id", id, "name", t.Name, "error", err)
+ }
+ }
+ }
+ if _, err := m.q.Delete.Exec(id); err != nil {
+ m.lo.Error("error deleting whatsapp template", "id", id, "error", err)
+ return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return nil
+}
+
+func (m *Manager) SyncFromMeta(ctx context.Context, inboxID int) (int, error) {
+ if m.client == nil || m.resolver == nil {
+ return 0, fmt.Errorf("whatsapp client not configured")
+ }
+ acc, err := m.resolver.WhatsAppAccount(inboxID)
+ if err != nil {
+ return 0, err
+ }
+ templates, err := m.client.FetchTemplates(ctx, acc)
+ if err != nil {
+ return 0, err
+ }
+ count := 0
+ metaIDs := make([]string, 0, len(templates))
+ for _, mt := range templates {
+ if mt.ID != "" {
+ metaIDs = append(metaIDs, mt.ID)
+ }
+ row := metaToRow(inboxID, mt)
+ var stored models.Template
+ if err := m.q.UpsertFromMeta.Get(&stored,
+ row.InboxID, row.MetaTemplateID, row.Name, row.Language, row.Category, row.Status,
+ row.HeaderType, row.HeaderContent, row.BodyContent, row.FooterContent,
+ row.Buttons, row.SampleValues, row.RejectionReason,
+ ); err != nil {
+ m.lo.Error("error upserting template from meta", "name", mt.Name, "error", err)
+ continue
+ }
+ count++
+ }
+ if _, err := m.q.DeleteMissingFromMeta.Exec(inboxID, pq.Array(metaIDs)); err != nil {
+ m.lo.Error("error pruning templates deleted on meta", "inbox_id", inboxID, "error", err)
+ }
+ return count, nil
+}
+
+// HandleStatusUpdate processes a Meta template status webhook, matching by Meta template id when present and falling back to (inbox, name).
+func (m *Manager) HandleStatusUpdate(inboxID int, metaTemplateID, name, language, event, reason string) error {
+ if metaTemplateID == "" && name == "" {
+ return fmt.Errorf("missing template identity in status update")
+ }
+ status := mapTemplateEventToStatus(event)
+ if status == "" {
+ m.lo.Info("ignoring unhandled whatsapp template status event", "name", name, "language", language, "event", event)
+ return nil
+ }
+ // Meta sends reason "NONE" on approval, which would read as a real rejection reason.
+ if strings.EqualFold(reason, "NONE") {
+ reason = ""
+ }
+ if metaTemplateID != "" {
+ res, err := m.q.UpdateStatusByMetaID.Exec(inboxID, metaTemplateID, status, reason)
+ if err != nil {
+ m.lo.Error("error applying template status update by meta id", "meta_template_id", metaTemplateID, "error", err)
+ return err
+ }
+ if n, _ := res.RowsAffected(); n > 0 {
+ return nil
+ }
+ }
+ if name == "" {
+ m.lo.Warn("template status update matched no row", "inbox_id", inboxID, "meta_template_id", metaTemplateID)
+ return nil
+ }
+ res, err := m.q.UpdateStatusByNameLanguage.Exec(inboxID, name, status, reason, language)
+ if err != nil {
+ m.lo.Error("error applying template status update by name", "name", name, "language", language, "error", err)
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ m.lo.Warn("template status update matched no row", "inbox_id", inboxID, "name", name, "language", language)
+ }
+ return nil
+}
+
+func metaToRow(inboxID int, mt whatsapp.MetaTemplate) models.Template {
+ row := models.Template{
+ InboxID: inboxID,
+ MetaTemplateID: null.StringFrom(mt.ID),
+ Name: mt.Name,
+ Language: mt.Language,
+ Category: mt.Category,
+ Status: strings.ToUpper(mt.Status),
+ }
+ for _, c := range mt.Components {
+ switch strings.ToUpper(c.Type) {
+ case "HEADER":
+ if c.Format != "" {
+ row.HeaderType = null.StringFrom(strings.ToUpper(c.Format))
+ }
+ if c.Text != "" {
+ row.HeaderContent = null.StringFrom(c.Text)
+ }
+ case "BODY":
+ row.BodyContent = c.Text
+ case "FOOTER":
+ if c.Text != "" {
+ row.FooterContent = null.StringFrom(c.Text)
+ }
+ case "BUTTONS":
+ if b, err := json.Marshal(c.Buttons); err == nil {
+ row.Buttons = b
+ }
+ }
+ }
+ if mt.RejectedReason != "" && !strings.EqualFold(mt.RejectedReason, "NONE") {
+ row.RejectionReason = null.StringFrom(mt.RejectedReason)
+ }
+ if row.Buttons == nil {
+ row.Buttons = json.RawMessage(`[]`)
+ }
+ if row.SampleValues == nil {
+ row.SampleValues = json.RawMessage(`{}`)
+ }
+ return row
+}
+
+func buildSubmission(t models.Template) (whatsapp.TemplateSubmission, error) {
+ sub := whatsapp.TemplateSubmission{
+ Name: t.Name,
+ Language: t.Language,
+ Category: strings.ToUpper(t.Category),
+ }
+
+ sampleValues := parseSampleValues(t.SampleValues)
+
+ headerText := ""
+ if t.HeaderType.Valid && strings.EqualFold(t.HeaderType.String, "TEXT") && t.HeaderContent.Valid {
+ headerText = t.HeaderContent.String
+ }
+ named := isNamed(headerText) || isNamed(t.BodyContent)
+ if named {
+ sub.ParameterFormat = "NAMED"
+ }
+
+ if t.HeaderType.Valid && t.HeaderType.String != "" {
+ hdr := whatsapp.TemplateComponent{
+ Type: "HEADER",
+ Format: strings.ToUpper(t.HeaderType.String),
+ }
+ if hdr.Format == "TEXT" && t.HeaderContent.Valid {
+ hdr.Text = t.HeaderContent.String
+ ex, err := buildExample(hdr.Text, sampleValues, "header_text")
+ if err != nil {
+ return whatsapp.TemplateSubmission{}, err
+ }
+ hdr.Example = ex
+ }
+ sub.Components = append(sub.Components, hdr)
+ }
+
+ body := whatsapp.TemplateComponent{Type: "BODY", Text: t.BodyContent}
+ ex, err := buildExample(body.Text, sampleValues, "body_text")
+ if err != nil {
+ return whatsapp.TemplateSubmission{}, err
+ }
+ body.Example = ex
+ sub.Components = append(sub.Components, body)
+
+ if t.FooterContent.Valid && t.FooterContent.String != "" {
+ sub.Components = append(sub.Components, whatsapp.TemplateComponent{
+ Type: "FOOTER",
+ Text: t.FooterContent.String,
+ })
+ }
+
+ if len(t.Buttons) > 0 && string(t.Buttons) != "[]" {
+ var btns []whatsapp.TemplateButton
+ if err := json.Unmarshal(t.Buttons, &btns); err == nil && len(btns) > 0 {
+ for i := range btns {
+ if !strings.EqualFold(btns[i].Type, "URL") || len(btns[i].Example) > 0 {
+ continue
+ }
+ keys := whatsapp.OrderedPlaceholders(btns[i].URL)
+ if len(keys) == 0 {
+ continue
+ }
+ url, err := substitutePlaceholders(btns[i].URL, keys, sampleValues)
+ if err != nil {
+ return whatsapp.TemplateSubmission{}, err
+ }
+ btns[i].Example = []string{url}
+ }
+ sub.Components = append(sub.Components, whatsapp.TemplateComponent{
+ Type: "BUTTONS",
+ Buttons: btns,
+ })
+ }
+ }
+
+ return sub, nil
+}
+
+// buildEdit reuses the submission components but drops name/language, which Meta does not allow changing on edit.
+func buildEdit(t models.Template) (whatsapp.TemplateEdit, error) {
+ sub, err := buildSubmission(t)
+ if err != nil {
+ return whatsapp.TemplateEdit{}, err
+ }
+ return whatsapp.TemplateEdit{
+ Category: sub.Category,
+ ParameterFormat: sub.ParameterFormat,
+ Components: sub.Components,
+ }, nil
+}
+
+func reservedContentChanged(existing, desired models.Template) bool {
+ if strings.TrimSpace(existing.BodyContent) != strings.TrimSpace(desired.BodyContent) {
+ return true
+ }
+ return !buttonsSurfaceEqual(existing.Buttons, desired.Buttons)
+}
+
+func buttonsSurfaceEqual(a, b json.RawMessage) bool {
+ var ab, bb []whatsapp.TemplateButton
+ if err := json.Unmarshal(a, &ab); err != nil {
+ ab = nil
+ }
+ if err := json.Unmarshal(b, &bb); err != nil {
+ bb = nil
+ }
+ if len(ab) != len(bb) {
+ return false
+ }
+ for i := range ab {
+ if strings.TrimSpace(ab[i].Text) != strings.TrimSpace(bb[i].Text) ||
+ strings.TrimSpace(ab[i].URL) != strings.TrimSpace(bb[i].URL) {
+ return false
+ }
+ }
+ return true
+}
+
+// parseSampleValues decodes sample_values JSON, tolerating non-string values from the frontend.
+func parseSampleValues(raw json.RawMessage) map[string]string {
+ if len(raw) == 0 || string(raw) == "{}" {
+ return nil
+ }
+ var anyMap map[string]any
+ if err := json.Unmarshal(raw, &anyMap); err != nil {
+ return nil
+ }
+ out := make(map[string]string, len(anyMap))
+ for k, v := range anyMap {
+ switch t := v.(type) {
+ case string:
+ out[k] = t
+ case float64:
+ out[k] = fmt.Sprintf("%v", t)
+ case bool:
+ out[k] = fmt.Sprintf("%v", t)
+ }
+ }
+ return out
+}
+
+func isNamed(text string) bool {
+ for _, key := range whatsapp.OrderedPlaceholders(text) {
+ if _, err := strconv.Atoi(key); err != nil {
+ return true
+ }
+ }
+ return false
+}
+
+func buildExample(text string, samples map[string]string, positionalKey string) (map[string]any, error) {
+ keys := whatsapp.OrderedPlaceholders(text)
+ if len(keys) == 0 {
+ return nil, nil
+ }
+ if isNamed(text) {
+ params := make([]map[string]any, 0, len(keys))
+ for _, key := range keys {
+ v, err := sampleValue(samples, key)
+ if err != nil {
+ return nil, err
+ }
+ params = append(params, map[string]any{"param_name": key, "example": v})
+ }
+ return map[string]any{positionalKey + "_named_params": params}, nil
+ }
+ vals := make([]string, 0, len(keys))
+ for _, key := range keys {
+ v, err := sampleValue(samples, key)
+ if err != nil {
+ return nil, err
+ }
+ vals = append(vals, v)
+ }
+ if positionalKey == "body_text" {
+ return map[string]any{positionalKey: [][]string{vals}}, nil
+ }
+ return map[string]any{positionalKey: vals}, nil
+}
+
+func substitutePlaceholders(text string, keys []string, samples map[string]string) (string, error) {
+ out := text
+ for _, key := range keys {
+ v, err := sampleValue(samples, key)
+ if err != nil {
+ return "", err
+ }
+ out = strings.ReplaceAll(out, "{{"+key+"}}", v)
+ }
+ return out, nil
+}
+
+func sampleValue(samples map[string]string, key string) (string, error) {
+ if v, ok := samples[key]; ok && v != "" {
+ return v, nil
+ }
+ return "", fmt.Errorf("missing sample value for placeholder {{%s}}", key)
+}
+
+func submitErrReason(err error) string {
+ if err == nil {
+ return ""
+ }
+ var me *whatsapp.MetaAPIError
+ if errors.As(err, &me) {
+ if me.UserMsg != "" {
+ return me.UserMsg
+ }
+ return me.Message
+ }
+ return err.Error()
+}
+
+// mapTemplateEventToStatus maps a Meta event to a local status; REINSTATED is an event, not a status, that means approved again.
+func mapTemplateEventToStatus(event string) string {
+ switch strings.ToUpper(event) {
+ case "APPROVED", "REINSTATED":
+ return models.StatusApproved
+ case "REJECTED":
+ return models.StatusRejected
+ case "PAUSED":
+ return models.StatusPaused
+ case "DISABLED":
+ return models.StatusDisabled
+ default:
+ return ""
+ }
+}
diff --git a/internal/whatsapp_template/template_test.go b/internal/whatsapp_template/template_test.go
new file mode 100644
index 000000000..03e1c181c
--- /dev/null
+++ b/internal/whatsapp_template/template_test.go
@@ -0,0 +1,384 @@
+package whatsapp_template
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/abhinavxd/libredesk/internal/whatsapp"
+ "github.com/abhinavxd/libredesk/internal/whatsapp_template/models"
+ "github.com/volatiletech/null/v9"
+)
+
+// A URL button with a {{1}} placeholder must ship a button example or Meta rejects the submission.
+func TestBuildSubmissionCSATButtonExample(t *testing.T) {
+ buttons, _ := json.Marshal([]map[string]any{{
+ "type": "URL",
+ "text": "Rate us",
+ "url": "http://localhost:9000/csat/{{1}}",
+ }})
+ sub, err := buildSubmission(models.Template{
+ InboxID: 2,
+ Name: "libredesk_csat_2",
+ Language: "en_US",
+ Category: "UTILITY",
+ BodyContent: "Your conversation has been resolved.",
+ Buttons: buttons,
+ SampleValues: json.RawMessage(`{"1":"example"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ for _, c := range sub.Components {
+ if c.Type != "BUTTONS" {
+ continue
+ }
+ want := "http://localhost:9000/csat/example"
+ if len(c.Buttons) == 0 || len(c.Buttons[0].Example) != 1 || c.Buttons[0].Example[0] != want {
+ t.Fatalf("expected URL button example %q, got %+v", want, c.Buttons)
+ }
+ return
+ }
+ t.Fatalf("expected a BUTTONS component, got %+v", sub.Components)
+}
+
+// Numbered placeholders are positional, so Meta wants a nested body example and no parameter_format.
+func TestBuildSubmissionPositionalBodyExample(t *testing.T) {
+ sub, err := buildSubmission(models.Template{
+ Name: "order_update",
+ Language: "en_US",
+ Category: "utility",
+ BodyContent: "Hi {{1}}, order {{2}} shipped.",
+ SampleValues: json.RawMessage(`{"1":"Ravi","2":"A1"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ if sub.ParameterFormat != "" {
+ t.Fatalf("expected no parameter_format for positional placeholders, got %q", sub.ParameterFormat)
+ }
+ if sub.Category != "UTILITY" {
+ t.Fatalf("expected the category to be upper-cased, got %q", sub.Category)
+ }
+ body, ok := componentByType(sub, "BODY")
+ if !ok {
+ t.Fatal("expected a BODY component")
+ }
+ vals, ok := body.Example["body_text"].([][]string)
+ if !ok || len(vals) != 1 || len(vals[0]) != 2 || vals[0][0] != "Ravi" || vals[0][1] != "A1" {
+ t.Fatalf("expected nested positional body example, got %#v", body.Example)
+ }
+}
+
+func TestBuildSubmissionNamedBodyAndHeaderExample(t *testing.T) {
+ sub, err := buildSubmission(models.Template{
+ Name: "order_update",
+ Language: "en_US",
+ Category: "UTILITY",
+ HeaderType: null.StringFrom("TEXT"),
+ HeaderContent: null.StringFrom("Order {{order_id}}"),
+ BodyContent: "Hi {{name}}, order {{order_id}} shipped.",
+ FooterContent: null.StringFrom("Libredesk"),
+ SampleValues: json.RawMessage(`{"name":"Ravi","order_id":"A1"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ if sub.ParameterFormat != "NAMED" {
+ t.Fatalf("expected parameter_format NAMED, got %q", sub.ParameterFormat)
+ }
+ header, ok := componentByType(sub, "HEADER")
+ if !ok {
+ t.Fatal("expected a HEADER component")
+ }
+ if _, ok := header.Example["header_text_named_params"]; !ok {
+ t.Fatalf("expected named header example, got %#v", header.Example)
+ }
+ body, _ := componentByType(sub, "BODY")
+ params, ok := body.Example["body_text_named_params"].([]map[string]any)
+ if !ok || len(params) != 2 || params[0]["param_name"] != "name" {
+ t.Fatalf("expected named body params in placeholder order, got %#v", body.Example)
+ }
+ if _, ok := componentByType(sub, "FOOTER"); !ok {
+ t.Fatal("expected a FOOTER component")
+ }
+}
+
+func TestBuildSubmissionMissingSampleValue(t *testing.T) {
+ _, err := buildSubmission(models.Template{
+ Name: "order_update",
+ Language: "en_US",
+ Category: "UTILITY",
+ BodyContent: "Hi {{name}}",
+ })
+ if err == nil {
+ t.Fatal("expected an error when a placeholder has no sample value")
+ }
+ if !strings.Contains(err.Error(), "name") {
+ t.Fatalf("expected the error to name the placeholder, got %q", err.Error())
+ }
+}
+
+// A media header carries no text, so it must ship without an example.
+func TestBuildSubmissionMediaHeaderHasNoExample(t *testing.T) {
+ sub, err := buildSubmission(models.Template{
+ Name: "promo",
+ Language: "en_US",
+ Category: "MARKETING",
+ HeaderType: null.StringFrom("IMAGE"),
+ BodyContent: "Seasonal offer.",
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ header, ok := componentByType(sub, "HEADER")
+ if !ok {
+ t.Fatal("expected a HEADER component")
+ }
+ if header.Format != "IMAGE" || header.Text != "" || header.Example != nil {
+ t.Fatalf("unexpected media header: %+v", header)
+ }
+}
+
+func TestBuildEditDropsNameAndLanguage(t *testing.T) {
+ edit, err := buildEdit(models.Template{
+ Name: "libredesk_csat_2",
+ Language: "en_US",
+ Category: "UTILITY",
+ BodyContent: "Hi {{1}}",
+ SampleValues: json.RawMessage(`{"1":"Ravi"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildEdit errored: %v", err)
+ }
+ raw, err := json.Marshal(edit)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ for _, field := range []string{`"name"`, `"language"`} {
+ if strings.Contains(string(raw), field) {
+ t.Fatalf("edit payload must not carry %s: %s", field, raw)
+ }
+ }
+ if len(edit.Components) == 0 {
+ t.Fatal("expected the edit to carry components")
+ }
+}
+
+func TestBuildSubmissionPositionalHeaderExample(t *testing.T) {
+ sub, err := buildSubmission(models.Template{
+ Name: "order_update", Language: "en_US", Category: "UTILITY",
+ HeaderType: null.StringFrom("TEXT"), HeaderContent: null.StringFrom("Order {{1}}"),
+ BodyContent: "Hi {{2}}", SampleValues: json.RawMessage(`{"1":"A1","2":"Ravi"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ header, _ := componentByType(sub, "HEADER")
+ vals, ok := header.Example["header_text"].([]string)
+ if !ok || len(vals) != 1 || vals[0] != "A1" {
+ t.Fatalf("expected a flat positional header example, got %#v", header.Example)
+ }
+}
+
+func TestBuildSubmissionMissingHeaderSampleValue(t *testing.T) {
+ _, err := buildSubmission(models.Template{
+ Name: "order_update", Language: "en_US", Category: "UTILITY",
+ HeaderType: null.StringFrom("TEXT"), HeaderContent: null.StringFrom("Order {{order_id}}"),
+ BodyContent: "Hi", SampleValues: json.RawMessage(`{}`),
+ })
+ if err == nil || !strings.Contains(err.Error(), "order_id") {
+ t.Fatalf("expected a missing header sample error, got %v", err)
+ }
+}
+
+func TestBuildSubmissionMissingButtonSampleValue(t *testing.T) {
+ buttons, _ := json.Marshal([]map[string]any{{"type": "URL", "text": "Track", "url": "https://x.test/{{track}}"}})
+ _, err := buildSubmission(models.Template{
+ Name: "order_update", Language: "en_US", Category: "UTILITY",
+ BodyContent: "Hi", Buttons: buttons, SampleValues: json.RawMessage(`{}`),
+ })
+ if err == nil || !strings.Contains(err.Error(), "track") {
+ t.Fatalf("expected a missing button sample error, got %v", err)
+ }
+}
+
+// Quick replies and static links carry no example, so they must pass through untouched.
+func TestBuildSubmissionButtonsWithoutPlaceholders(t *testing.T) {
+ buttons, _ := json.Marshal([]map[string]any{
+ {"type": "QUICK_REPLY", "text": "Yes"},
+ {"type": "URL", "text": "Home", "url": "https://x.test/"},
+ })
+ sub, err := buildSubmission(models.Template{
+ Name: "promo", Language: "en_US", Category: "MARKETING", BodyContent: "Hi", Buttons: buttons,
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ comp, ok := componentByType(sub, "BUTTONS")
+ if !ok || len(comp.Buttons) != 2 {
+ t.Fatalf("unexpected buttons component: %+v", comp)
+ }
+ for _, b := range comp.Buttons {
+ if len(b.Example) != 0 {
+ t.Fatalf("expected no example on %s, got %v", b.Text, b.Example)
+ }
+ }
+}
+
+// A button that already carries its example must not be rewritten from the sample values.
+func TestBuildSubmissionKeepsExistingButtonExample(t *testing.T) {
+ buttons, _ := json.Marshal([]map[string]any{{"type": "URL", "text": "Track", "url": "https://x.test/{{1}}", "example": []string{"https://x.test/kept"}}})
+ sub, err := buildSubmission(models.Template{
+ Name: "order_update", Language: "en_US", Category: "UTILITY",
+ BodyContent: "Hi", Buttons: buttons, SampleValues: json.RawMessage(`{"1":"other"}`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ comp, _ := componentByType(sub, "BUTTONS")
+ if comp.Buttons[0].Example[0] != "https://x.test/kept" {
+ t.Fatalf("unexpected example: %v", comp.Buttons[0].Example)
+ }
+}
+
+// An unreadable buttons column must not stop the rest of the template from being submitted.
+func TestBuildSubmissionSkipsUnreadableButtons(t *testing.T) {
+ sub, err := buildSubmission(models.Template{
+ Name: "promo", Language: "en_US", Category: "MARKETING", BodyContent: "Hi", Buttons: json.RawMessage(`not json`),
+ })
+ if err != nil {
+ t.Fatalf("buildSubmission errored: %v", err)
+ }
+ if _, ok := componentByType(sub, "BUTTONS"); ok {
+ t.Fatal("expected no buttons component")
+ }
+}
+
+func TestMapTemplateEventToStatus(t *testing.T) {
+ tests := map[string]string{
+ "APPROVED": models.StatusApproved,
+ "approved": models.StatusApproved,
+ "REINSTATED": models.StatusApproved,
+ "REJECTED": models.StatusRejected,
+ "PAUSED": models.StatusPaused,
+ "DISABLED": models.StatusDisabled,
+ "PENDING_DELETION": "",
+ "FLAGGED": "",
+ "": "",
+ "some_future_meta_event": "",
+ "TEMPLATE_QUALITY_UPDATE ": "",
+ }
+ for event, want := range tests {
+ if got := mapTemplateEventToStatus(event); got != want {
+ t.Errorf("%q: expected %q, got %q", event, want, got)
+ }
+ }
+}
+
+func TestMetaToRow(t *testing.T) {
+ mt := whatsapp.MetaTemplate{
+ ID: "123",
+ Name: "order_update",
+ Language: "en_US",
+ Category: "UTILITY",
+ Status: "approved",
+ Components: []whatsapp.TemplateComponent{
+ {Type: "header", Format: "text", Text: "Order {{1}}"},
+ {Type: "BODY", Text: "Hi {{1}}"},
+ {Type: "FOOTER", Text: "Libredesk"},
+ {Type: "BUTTONS", Buttons: []whatsapp.TemplateButton{{Type: "URL", Text: "Track", URL: "https://x.test/{{1}}"}}},
+ },
+ RejectedReason: "NONE",
+ }
+ row := metaToRow(9, mt)
+ if row.InboxID != 9 || row.MetaTemplateID.String != "123" || row.Status != "APPROVED" {
+ t.Fatalf("unexpected row: %+v", row)
+ }
+ if row.HeaderType.String != "TEXT" || row.HeaderContent.String != "Order {{1}}" {
+ t.Fatalf("unexpected header: %+v", row)
+ }
+ if row.BodyContent != "Hi {{1}}" || row.FooterContent.String != "Libredesk" {
+ t.Fatalf("unexpected body/footer: %+v", row)
+ }
+ // Meta sends "NONE" when nothing is wrong, which would read as a real rejection reason.
+ if row.RejectionReason.Valid {
+ t.Fatalf("expected no rejection reason, got %q", row.RejectionReason.String)
+ }
+ var btns []whatsapp.TemplateButton
+ if err := json.Unmarshal(row.Buttons, &btns); err != nil || len(btns) != 1 || btns[0].Text != "Track" {
+ t.Fatalf("unexpected buttons %s (err %v)", row.Buttons, err)
+ }
+
+ empty := metaToRow(9, whatsapp.MetaTemplate{Name: "x", Language: "en_US", Status: "PENDING"})
+ if string(empty.Buttons) != `[]` || string(empty.SampleValues) != `{}` {
+ t.Fatalf("expected JSON defaults, got buttons=%s sample=%s", empty.Buttons, empty.SampleValues)
+ }
+
+ rejected := metaToRow(9, whatsapp.MetaTemplate{Name: "x", Language: "en_US", Status: "REJECTED", RejectedReason: "INVALID_FORMAT"})
+ if rejected.RejectionReason.String != "INVALID_FORMAT" {
+ t.Fatalf("expected the real rejection reason to be kept, got %+v", rejected.RejectionReason)
+ }
+}
+
+func TestReservedContentChanged(t *testing.T) {
+ buttons := func(text, url string) json.RawMessage {
+ b, _ := json.Marshal([]map[string]any{{"type": "URL", "text": text, "url": url}})
+ return b
+ }
+ existing := models.Template{BodyContent: "Rate us please", Buttons: buttons("Rate us", "https://x.test/csat/{{1}}")}
+
+ tests := []struct {
+ name string
+ desired models.Template
+ want bool
+ }{
+ {"identical", models.Template{BodyContent: "Rate us please", Buttons: buttons("Rate us", "https://x.test/csat/{{1}}")}, false},
+ {"whitespace only", models.Template{BodyContent: " Rate us please ", Buttons: buttons(" Rate us ", "https://x.test/csat/{{1}}")}, false},
+ {"body changed", models.Template{BodyContent: "New copy", Buttons: buttons("Rate us", "https://x.test/csat/{{1}}")}, true},
+ {"button text changed", models.Template{BodyContent: "Rate us please", Buttons: buttons("Give feedback", "https://x.test/csat/{{1}}")}, true},
+ {"button url changed", models.Template{BodyContent: "Rate us please", Buttons: buttons("Rate us", "https://new.test/csat/{{1}}")}, true},
+ {"button removed", models.Template{BodyContent: "Rate us please", Buttons: json.RawMessage(`[]`)}, true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := reservedContentChanged(existing, tc.desired); got != tc.want {
+ t.Fatalf("expected %v, got %v", tc.want, got)
+ }
+ })
+ }
+}
+
+func TestParseSampleValues(t *testing.T) {
+ got := parseSampleValues(json.RawMessage(`{"name":"Ravi","count":2,"flag":true}`))
+ if got["name"] != "Ravi" || got["count"] != "2" || got["flag"] != "true" {
+ t.Fatalf("unexpected sample values: %+v", got)
+ }
+ if parseSampleValues(json.RawMessage(`{}`)) != nil {
+ t.Fatal("expected nil for an empty object")
+ }
+ if parseSampleValues(nil) != nil {
+ t.Fatal("expected nil for absent sample values")
+ }
+ if parseSampleValues(json.RawMessage(`not json`)) != nil {
+ t.Fatal("expected nil for malformed sample values")
+ }
+}
+
+func TestCSATTemplateName(t *testing.T) {
+ if got := models.CSATTemplateName(15); got != "libredesk_csat_15" {
+ t.Fatalf("unexpected reserved name %q", got)
+ }
+ if !strings.HasPrefix(models.CSATTemplateName(15), models.CSATTemplateNamePrefix) {
+ t.Fatal("reserved names must carry the reserved prefix the delete guard checks")
+ }
+}
+
+func componentByType(sub whatsapp.TemplateSubmission, typ string) (whatsapp.TemplateComponent, bool) {
+ for _, c := range sub.Components {
+ if c.Type == typ {
+ return c, true
+ }
+ }
+ return whatsapp.TemplateComponent{}, false
+}
diff --git a/schema.sql b/schema.sql
index 64d67d150..498c2851c 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,6 +1,6 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email', 'livechat');
+DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email', 'livechat', 'whatsapp');
DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM ('incoming','outgoing','activity');
DROP TYPE IF EXISTS "message_sender_type" CASCADE; CREATE TYPE "message_sender_type" AS ENUM ('agent','contact');
DROP TYPE IF EXISTS "message_status" CASCADE; CREATE TYPE "message_status" AS ENUM ('received','sent','failed','pending');
@@ -117,6 +117,7 @@ CREATE TABLE inboxes (
enabled bool DEFAULT TRUE NOT NULL,
csat_enabled bool DEFAULT false NOT NULL,
prompt_tags_on_reply bool DEFAULT false NOT NULL,
+ reopen_window_hours INT DEFAULT 0 NOT NULL,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
"from" TEXT NULL,
from_name_template TEXT NOT NULL DEFAULT '',
@@ -193,6 +194,7 @@ CREATE TABLE users (
CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140)
);
CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops);
+CREATE INDEX index_tgrm_users_on_phone_number ON users USING GIN (phone_number gin_trgm_ops);
CREATE INDEX index_users_on_api_key ON users(api_key);
CREATE INDEX index_users_on_availability_status_when_agent ON users(availability_status) WHERE type = 'agent' AND deleted_at IS NULL;
CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_agent
@@ -268,6 +270,7 @@ CREATE TABLE conversations (
last_reply_at TIMESTAMPTZ NULL,
closed_at TIMESTAMPTZ NULL,
resolved_at TIMESTAMPTZ NULL,
+ last_resolved_at TIMESTAMPTZ NULL,
"subject" TEXT NULL,
waiting_since TIMESTAMPTZ NULL,
@@ -281,7 +284,8 @@ CREATE TABLE conversations (
last_interaction_at TIMESTAMPTZ NULL,
next_sla_deadline_at TIMESTAMPTZ NULL,
snoozed_until TIMESTAMPTZ NULL,
- last_continuity_email_sent_at TIMESTAMPTZ NULL
+ last_continuity_email_sent_at TIMESTAMPTZ NULL,
+ last_inbound_at TIMESTAMPTZ NULL
);
CREATE INDEX index_conversations_on_assigned_user_id ON conversations (assigned_user_id);
CREATE INDEX index_conversations_on_assigned_team_id ON conversations (assigned_team_id);
@@ -297,6 +301,7 @@ CREATE INDEX index_conversations_on_last_interaction_at ON conversations (last_i
CREATE INDEX index_conversations_on_next_sla_deadline_at ON conversations (next_sla_deadline_at);
CREATE INDEX index_conversations_on_waiting_since ON conversations (waiting_since);
CREATE INDEX index_conversations_on_last_continuity_email_sent_at ON conversations (last_continuity_email_sent_at);
+CREATE INDEX index_conversations_on_last_inbound_at ON conversations (last_inbound_at);
DROP TABLE IF EXISTS conversation_messages CASCADE;
CREATE TABLE conversation_messages (
@@ -500,6 +505,34 @@ CREATE TABLE templates (
CREATE UNIQUE INDEX index_unique_templates_on_is_default_when_is_default_is_true ON templates USING btree (is_default)
WHERE (is_default = true);
+DROP TABLE IF EXISTS whatsapp_templates CASCADE;
+CREATE TABLE whatsapp_templates (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ inbox_id INT REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+ meta_template_id TEXT NULL,
+ name TEXT NOT NULL,
+ language TEXT NOT NULL,
+ category TEXT NOT NULL,
+ status TEXT DEFAULT 'PENDING' NOT NULL,
+ header_type TEXT NULL,
+ header_content TEXT NULL,
+ body_content TEXT NOT NULL,
+ footer_content TEXT NULL,
+ buttons JSONB DEFAULT '[]'::jsonb NOT NULL,
+ sample_values JSONB DEFAULT '{}'::jsonb NOT NULL,
+ rejection_reason TEXT NULL,
+ CONSTRAINT constraint_whatsapp_templates_on_name CHECK (length(name) <= 512),
+ CONSTRAINT constraint_whatsapp_templates_on_language CHECK (length(language) <= 20),
+ CONSTRAINT constraint_whatsapp_templates_on_category CHECK (length(category) <= 32),
+ CONSTRAINT constraint_whatsapp_templates_on_status CHECK (length(status) <= 32),
+ CONSTRAINT constraint_whatsapp_templates_on_header_type CHECK (length(header_type) <= 32)
+);
+CREATE UNIQUE INDEX index_unique_whatsapp_templates_on_inbox_name_language ON whatsapp_templates (inbox_id, name, language);
+CREATE INDEX index_whatsapp_templates_on_inbox_id ON whatsapp_templates (inbox_id);
+CREATE INDEX index_whatsapp_templates_on_meta_template_id ON whatsapp_templates (meta_template_id);
+
DROP TABLE IF EXISTS conversation_tags CASCADE;
CREATE TABLE conversation_tags (
id BIGSERIAL PRIMARY KEY,
@@ -884,6 +917,19 @@ CREATE TABLE contact_notes (
);
CREATE INDEX index_contact_notes_on_contact_id_created_at ON contact_notes (contact_id, created_at);
+DROP TABLE IF EXISTS contact_channel_identities CASCADE;
+CREATE TABLE contact_channel_identities (
+ id BIGSERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ contact_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+ channel channels NOT NULL,
+ identifier TEXT NOT NULL,
+ CONSTRAINT constraint_contact_channel_identities_on_identifier CHECK (length(identifier) <= 1000)
+);
+CREATE UNIQUE INDEX index_unique_contact_channel_identities_on_channel_identifier ON contact_channel_identities (channel, identifier);
+CREATE INDEX index_contact_channel_identities_on_contact_id ON contact_channel_identities (contact_id);
+
DROP TABLE IF EXISTS activity_logs CASCADE;
CREATE TABLE activity_logs (
id BIGSERIAL PRIMARY KEY,