fix(whatsapp): preserve and display flow responses (#15279)
WhatsApp Flow submissions currently reach Chatwoot as `interactive.nfm_reply` messages, but their structured answers are discarded. Agents see an empty message and outbound webhooks cannot access the submitted data. This change preserves the Flow response in message content attributes and renders a readable response card in the conversation. Existing WhatsApp messages and other interactive message types remain unchanged. Fixes https://github.com/chatwoot/chatwoot/issues/13970 <img width="1234" height="1350" alt="CleanShot 2026-08-04 at 00 40 14@2x" src="https://github.com/user-attachments/assets/884b3a5c-bab3-4196-8037-776e6c41ab2a" /> ### How to reproduce 1. Send an approved WhatsApp template containing a Flow button. 2. Complete and submit the Flow from WhatsApp. 3. Open the conversation in Chatwoot. 4. Observe that the incoming message has no visible content and the submitted fields are absent from the message webhook. ### What changed - Parse `interactive.nfm_reply.response_json` for incoming WhatsApp Cloud messages. - Store the Flow name, body, and structured response under `content_attributes.whatsapp_flow_response`. - Add a dedicated conversation bubble that formats submitted field names and values. - Preserve original response keys while converting message attributes to the frontend shape. - Keep `flow_token` available in stored/webhook data while omitting it from the agent-facing card. - Add focused backend, helper, and component regression coverage. ### How to test 1. Send a WhatsApp Flow template to a contact. 2. Submit the Flow with multiple field types, including free text and a selection. 3. Confirm the incoming conversation message displays each submitted field and value. 4. Confirm an empty-answer Flow still renders a visible “Submitted a flow response” message. 5. Inspect the `message_created` webhook and confirm the structured response is present in `content_attributes.whatsapp_flow_response.response_json`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import LocationBubble from './bubbles/Location.vue';
|
||||
import CSATBubble from './bubbles/CSAT.vue';
|
||||
import FormBubble from './bubbles/Form.vue';
|
||||
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
import WhatsappFlowResponseBubble from './bubbles/WhatsappFlowResponse.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
@@ -296,6 +297,10 @@ const componentToRender = computed(() => {
|
||||
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes?.whatsappFlowResponse) {
|
||||
return WhatsappFlowResponseBubble;
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.INPUT_CSAT) {
|
||||
return CSATBubble;
|
||||
}
|
||||
@@ -409,6 +414,8 @@ const shouldRenderMessage = computed(() => {
|
||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||
const isAnIntegrationMessage =
|
||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||
const hasWhatsappFlowResponse =
|
||||
!!props.contentAttributes?.whatsappFlowResponse;
|
||||
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||
|
||||
@@ -418,6 +425,7 @@ const shouldRenderMessage = computed(() => {
|
||||
isEmailContentType ||
|
||||
isUnsupported ||
|
||||
isAnIntegrationMessage ||
|
||||
hasWhatsappFlowResponse ||
|
||||
isFailedMessage ||
|
||||
hasExternalError
|
||||
);
|
||||
|
||||
@@ -44,7 +44,10 @@ const emit = defineEmits(['retry']);
|
||||
const allMessages = computed(() => {
|
||||
return useCamelCase(props.messages, {
|
||||
deep: true,
|
||||
stopPaths: ['content_attributes.translations'],
|
||||
stopPaths: [
|
||||
'content_attributes.translations',
|
||||
'content_attributes.whatsapp_flow_response.response_json',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { buildFlowResponseEntries } from '../helpers/whatsappFlowResponse.js';
|
||||
|
||||
const { content, contentAttributes } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const responseEntries = computed(() => {
|
||||
const response =
|
||||
contentAttributes.value?.whatsappFlowResponse?.responseJson ?? {};
|
||||
|
||||
return buildFlowResponseEntries(response);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="px-4 py-3 min-w-64 max-w-lg"
|
||||
data-bubble-name="whatsapp-flow-response"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ content || t('CONVERSATION.WHATSAPP_FLOW_RESPONSE') }}
|
||||
</div>
|
||||
<dl v-if="responseEntries.length" class="mt-3 space-y-3">
|
||||
<div v-for="entry in responseEntries" :key="entry.key">
|
||||
<dt class="text-xs text-n-slate-11">
|
||||
{{ entry.label }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 whitespace-pre-wrap break-words">
|
||||
{{ entry.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import WhatsappFlowResponse from '../WhatsappFlowResponse.vue';
|
||||
import { provideMessageContext } from '../../provider.js';
|
||||
|
||||
const mountFlowResponse = () => {
|
||||
const TestHost = defineComponent({
|
||||
components: { WhatsappFlowResponse },
|
||||
setup() {
|
||||
provideMessageContext({
|
||||
content: ref('Submitted a flow response'),
|
||||
contentAttributes: ref({
|
||||
whatsappFlowResponse: {
|
||||
responseJson: {
|
||||
flow_token: 'correlation-token',
|
||||
rating: 'excellent',
|
||||
comments: 'Great support',
|
||||
appointment: { day: 'Monday' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
template: '<WhatsappFlowResponse />',
|
||||
});
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
CONVERSATION: {
|
||||
WHATSAPP_FLOW_RESPONSE: 'Submitted a flow response',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mount(TestHost, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseBubble: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('WhatsappFlowResponse', () => {
|
||||
it('renders the submitted fields without exposing the correlation token', () => {
|
||||
const wrapper = mountFlowResponse();
|
||||
const labels = wrapper.findAll('dt').map(item => item.text());
|
||||
const values = wrapper.findAll('dd').map(item => item.text());
|
||||
|
||||
expect(wrapper.text()).toContain('Submitted a flow response');
|
||||
expect(labels).toEqual(['Rating', 'Comments', 'Appointment']);
|
||||
expect(values).toEqual([
|
||||
'excellent',
|
||||
'Great support',
|
||||
'{\n "day": "Monday"\n}',
|
||||
]);
|
||||
expect(wrapper.text()).not.toContain('correlation-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
buildFlowResponseEntries,
|
||||
formatFlowResponseLabel,
|
||||
formatFlowResponseValue,
|
||||
} from '../whatsappFlowResponse';
|
||||
|
||||
describe('whatsappFlowResponse', () => {
|
||||
describe('formatFlowResponseLabel', () => {
|
||||
it('formats snake case and generated Flow field names', () => {
|
||||
expect(formatFlowResponseLabel('flow_token')).toBe('Flow Token');
|
||||
expect(formatFlowResponseLabel('screen_0_Rating_0')).toBe(
|
||||
'Screen 0 Rating 0'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats camel case labels', () => {
|
||||
expect(formatFlowResponseLabel('appointmentDate')).toBe(
|
||||
'Appointment Date'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFlowResponseValue', () => {
|
||||
it('formats primitive values', () => {
|
||||
expect(formatFlowResponseValue('excellent')).toBe('excellent');
|
||||
expect(formatFlowResponseValue(false)).toBe('false');
|
||||
expect(formatFlowResponseValue(3)).toBe('3');
|
||||
});
|
||||
|
||||
it('formats empty values', () => {
|
||||
expect(formatFlowResponseValue(null)).toBe('—');
|
||||
expect(formatFlowResponseValue('')).toBe('—');
|
||||
});
|
||||
|
||||
it('formats structured values without losing data', () => {
|
||||
expect(formatFlowResponseValue({ day: 'Monday' })).toBe(
|
||||
'{\n "day": "Monday"\n}'
|
||||
);
|
||||
expect(formatFlowResponseValue(['morning', 'afternoon'])).toBe(
|
||||
'[\n "morning",\n "afternoon"\n]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFlowResponseEntries', () => {
|
||||
it('builds readable answer entries while keeping the flow token as metadata', () => {
|
||||
expect(
|
||||
buildFlowResponseEntries({
|
||||
flow_token: 'correlation-token',
|
||||
rating: 'excellent',
|
||||
appointment: { day: 'Monday' },
|
||||
})
|
||||
).toEqual([
|
||||
{ key: 'rating', label: 'Rating', value: 'excellent' },
|
||||
{
|
||||
key: 'appointment',
|
||||
label: 'Appointment',
|
||||
value: '{\n "day": "Monday"\n}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('displays a raw response as a single readable entry', () => {
|
||||
expect(buildFlowResponseEntries('{invalid-json')).toEqual([
|
||||
{
|
||||
key: 'response',
|
||||
label: 'Response',
|
||||
value: '{invalid-json',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const formatFlowResponseLabel = key =>
|
||||
key
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, character => character.toUpperCase());
|
||||
|
||||
export const formatFlowResponseValue = value => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const buildFlowResponseEntries = response => {
|
||||
const entries =
|
||||
response && typeof response === 'object' && !Array.isArray(response)
|
||||
? Object.entries(response)
|
||||
: [['response', response]];
|
||||
|
||||
return entries
|
||||
.filter(([key]) => key !== 'flow_token')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFlowResponseLabel(key),
|
||||
value: formatFlowResponseValue(value),
|
||||
}));
|
||||
};
|
||||
@@ -66,6 +66,8 @@ const MessageControl = Symbol('MessageControl');
|
||||
* @property {EmailContent} [email] - Email content and metadata
|
||||
* @property {string|null} [ccEmail] - CC email addresses
|
||||
* @property {string|null} [bccEmail] - BCC email addresses
|
||||
* @property {Object} [whatsappFlowResponse] - WhatsApp Flow response metadata
|
||||
* @property {Record<string, unknown>|string} [whatsappFlowResponse.responseJson] - Structured fields or the raw response submitted by the contact
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"WHATSAPP_FLOW_RESPONSE": "Submitted a flow response",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
|
||||
Reference in New Issue
Block a user