diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index de3d10081..3f0309024 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :check_authorization before_action :agent_bot, except: [:index, :create] diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 482b001d6..3b7fafad5 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController - before_action :current_account before_action :authorize_account_update, only: [:update] def show diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 6889d30a4..f678fee42 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :fetch_label, except: [:index, :create] before_action :check_authorization diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 580ae77c6..46d89a1ba 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } @@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: private + def ensure_embedded_signup_enabled + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' - - # Reconfiguring a live embedded-signup channel requires the feature flag. + return true if ChatwootApp.chatwoot_cloud? return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' true diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js new file mode 100644 index 000000000..a557b5938 --- /dev/null +++ b/app/javascript/dashboard/api/captain/agentSessions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainAgentSessions extends ApiClient { + constructor() { + super('captain/agent_sessions', { accountScoped: true }); + } +} + +export default new CaptainAgentSessions(); diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 1fc17798d..5af6110ab 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range }) { - return axios.get(`${this.url}/${assistantId}/stats`, { + getStats({ assistantId, range, signal }) { + const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, - }); + }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/stats`, requestConfig); } - getSummary({ assistantId, range }) { + getSummary({ assistantId, range, stats }) { return axios.get(`${this.url}/${assistantId}/summary`, { - params: { range, timezone_offset: getTimezoneOffset() }, + params: { range, timezone_offset: getTimezoneOffset(), stats }, }); } diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js index ec24aae34..d458c1e5d 100644 --- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient { return axios.get(`${this.url}/${callId}`).then(r => r.data); } - initiate(conversationId, sdpOffer) { + // Either conversationId, or contactId + inboxId to let the BE resolve the conversation. + initiate({ conversationId, contactId, inboxId }, sdpOffer) { return axios .post(`${this.url}/initiate`, { conversation_id: conversationId, + contact_id: contactId, + inbox_id: inboxId, sdp_offer: sdpOffer, }) .then(r => r.data); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue index 8b0a56bfd..dd2b3af37 100644 --- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -25,8 +25,11 @@ const route = useRoute(); const kind = computed(() => getCallKind(props.call)); -const contactName = computed( - () => props.call.contact.name || props.call.contact.phoneNumber +const contactName = computed(() => + (props.call.contact.name || props.call.contact.phoneNumber || '').replace( + /^\+/, + '' + ) ); const agentActionLabel = computed(() => { diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 7e2b6f0c4..5c7ec691d 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; -import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -83,39 +82,18 @@ const navigateToConversation = conversationId => { const whatsappCallSession = useWhatsappCallSession(); -// Find the most recent open conversation for this contact in the picked inbox. -// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). -// Pass inboxId so the BE applies the filter before the 20-row cap — without it, -// contacts whose latest WhatsApp conversation falls outside the 20 most recent -// across all inboxes would be treated as having no conversation. -const findWhatsappConversationId = async inboxId => { - const { data } = await ContactAPI.getConversations(props.contactId, { - inboxId, - }); - const conversations = data?.payload || []; - const match = [...conversations].sort( - (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) - )[0]; - return match?.id || null; -}; - const startWhatsappCall = async (inboxId, conversationIdHint) => { - // WhatsApp /initiate is conversation-scoped, so we must hand it a - // conversation. Use the caller's hint when given (in-conversation flow); - // otherwise pick the most recent one in the inbox. - const conversationId = - conversationIdHint || (await findWhatsappConversationId(inboxId)); - if (!conversationId) { - useAlert(t('CONTACT_PANEL.CALL_FAILED')); - return; - } - - const response = - await whatsappCallSession.initiateOutboundCall(conversationId); + const response = await whatsappCallSession.initiateOutboundCall( + conversationIdHint + ? { conversationId: conversationIdHint } + : { contactId: props.contactId, inboxId } + ); // The composable returns { status: 'locked' } when an init is already in // flight or a call is already active; treat that as a soft no-op rather than // claiming success. if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + + const conversationId = response?.conversation_id || conversationIdHint; if (!response?.id) { // Permission template path returns no call id. Mirror the header button and // surface whether the request was just sent or is already pending instead of diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue index cf66a0a2f..9a68b71ce 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -9,6 +9,7 @@ const props = defineProps({ // null = neutral, true = good direction, false = bad direction trendGood: { type: Boolean, default: null }, clickable: { type: Boolean, default: false }, + loading: { type: Boolean, default: false }, }); const emit = defineEmits(['click']); @@ -45,7 +46,11 @@ const onActivate = () => { class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100" /> -
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue index df336a7e4..5e868956f 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue @@ -9,6 +9,10 @@ const props = defineProps({ type: String, default: '30', }, + stats: { + type: Object, + default: null, + }, }); const route = useRoute(); @@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId); const welcomeMarkdown = ref(''); const isLoading = ref(false); +// Increments on every fetch so a slow response for a superseded +// range/stats/assistant can't overwrite the latest request's state. +let fetchToken = 0; + const fetchSummary = async () => { + fetchToken += 1; + const token = fetchToken; + + if (!props.stats) { + welcomeMarkdown.value = ''; + isLoading.value = false; + return; + } + isLoading.value = true; + let message = ''; try { const { data } = await CaptainAssistant.getSummary({ assistantId: assistantId.value, range: props.range, + stats: props.stats, }); - welcomeMarkdown.value = data.message ?? ''; + message = data.message ?? ''; } catch { - welcomeMarkdown.value = ''; - } finally { - isLoading.value = false; + message = ''; } + + if (token !== fetchToken) return; + welcomeMarkdown.value = message; + isLoading.value = false; }; -watch([() => props.range, assistantId], fetchSummary, { immediate: true }); +watch([() => props.range, () => props.stats, assistantId], fetchSummary, { + immediate: true, +}); // Render through the shared markdown formatter (html disabled, so it is safe) // used everywhere else for Captain output, instead of a bespoke parser. It diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue index 4d526d558..1390ea0f7 100644 --- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue +++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue @@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; -import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper'; -import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; +import { + isTwilioComplete, + isTwilioMediaTemplate, + getTwilioMediaVariableKey, + getTwilioMediaUrl, + applyTwilioMediaFilename, +} from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -40,30 +45,23 @@ const templateBody = computed(() => { return props.template.body || ''; }); -const hasMediaTemplate = computed(() => { - return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; -}); +// Media-template detection and variable extraction are shared with the mobile +// app via @chatwoot/utils. +const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); const hasVariables = computed(() => { return templateBody.value?.match(VARIABLE_PATTERN) !== null; }); -const mediaVariableKey = computed(() => { - if (!hasMediaTemplate.value) return null; - const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0]; - if (!mediaUrl) return null; - return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; -}); +const mediaVariableKey = computed(() => + getTwilioMediaVariableKey(props.template) +); -const hasMediaVariable = computed(() => { - return hasMediaTemplate.value && mediaVariableKey.value !== null; -}); +const hasMediaVariable = computed(() => mediaVariableKey.value !== null); -const templateMediaUrl = computed(() => { - if (!hasMediaTemplate.value) return ''; - - return props.template?.types?.['twilio/media']?.media?.[0] || ''; -}); +const templateMediaUrl = computed(() => + hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' +); const variablePattern = computed(() => { if (!hasVariables.value) return []; @@ -83,26 +81,10 @@ const renderedTemplate = computed(() => { return rendered; }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaVariable.value) return false; - - if (hasVariables.value) { - const hasEmptyVariable = variablePattern.value.some( - variable => !processedParams.value[variable] - ); - if (hasEmptyVariable) return true; - } - - if ( - hasMediaVariable.value && - mediaVariableKey.value && - !processedParams.value[mediaVariableKey.value] - ) { - return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isTwilioComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { @@ -135,19 +117,11 @@ const sendMessage = () => { const { friendly_name, language } = props.template; - // Process parameters and extract filename from media URL if needed - const processedParameters = { ...processedParams.value }; - - // For media templates, extract filename from full URL - if ( - hasMediaVariable.value && - mediaVariableKey.value && - processedParameters[mediaVariableKey.value] - ) { - processedParameters[mediaVariableKey.value] = extractFilenameFromUrl( - processedParameters[mediaVariableKey.value] - ); - } + // For media templates, reduce the media URL to a filename before sending. + const processedParameters = applyTwilioMediaFilename( + props.template, + processedParams.value + ); const payload = { message: renderedTemplate.value, diff --git a/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue new file mode 100644 index 000000000..752616109 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue @@ -0,0 +1,342 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index 457b583ea..e799bd755 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import MessageMeta from '../MessageMeta.vue'; +import CaptainGenerationDetails from '../CaptainGenerationDetails.vue'; import { emitter } from 'shared/helpers/mitt'; import { useMessageContext } from '../provider.js'; @@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n'; import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import { BUS_EVENTS } from 'shared/constants/busEvents'; -import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants'; const props = defineProps({ hideMeta: { type: Boolean, default: false }, }); -const { variant, orientation, inReplyTo, shouldGroupWithNext } = - useMessageContext(); +const { + variant, + orientation, + inReplyTo, + shouldGroupWithNext, + id, + sender, + senderType, +} = useMessageContext(); const { t } = useI18n(); +const isCaptainMessage = computed( + () => + (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT +); + +const metaColorClass = computed(() => + variant.value === MESSAGE_VARIANTS.PRIVATE + ? 'text-n-amber-12/50' + : 'text-n-slate-11' +); + +const emailMetaClass = computed(() => + variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '' +); + const varaintBaseMap = { [MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12', [MESSAGE_VARIANTS.PRIVATE]: @@ -114,16 +137,21 @@ const replyToPreview = computed(() => { />
- +
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 34eb1eaff..1a127d9e6 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -263,9 +263,9 @@ const handleCallBack = async () => { if (!canCallBack.value || isInitiatingCall.value) return; try { if (isWhatsapp.value) { - const response = await whatsappCallSession.initiateOutboundCall( - conversationId.value - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: conversationId.value, + }); if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; // Permission template path returns no call id — show banner, no widget yet. if (!response?.id) { diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue index 6df06642c..620955cb4 100644 --- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue +++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue @@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; +import { isWhatsAppComplete } from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; import { buildTemplateParameters, @@ -84,29 +85,10 @@ const renderedTemplate = computed(() => { return replaceTemplateVariables(bodyText.value, processedParams.value); }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaHeader.value) return false; - - if (hasMediaHeader.value && !processedParams.value.header?.media_url) { - return true; - } - - if (hasVariables.value && processedParams.value.body) { - const hasEmptyBodyVariable = Object.values(processedParams.value.body).some( - value => !value - ); - if (hasEmptyBodyVariable) return true; - } - - if (processedParams.value.buttons) { - const hasEmptyButtonParameter = processedParams.value.buttons.some( - button => !button.parameter - ); - if (hasEmptyButtonParameter) return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isWhatsAppComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue index d0d2a2778..f59df929c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue @@ -69,9 +69,9 @@ const callButtonTooltip = computed(() => const startWhatsappCall = async () => { if (whatsappCallSession.isInitiating.value) return; try { - const response = await whatsappCallSession.initiateOutboundCall( - props.chat.id - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: props.chat.id, + }); // Composable returns LOCKED when init is already in flight or a call is // active; soft no-op so a parallel click doesn't trigger a banner. diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index b934c0997..9d17c6b06 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -308,7 +308,8 @@ export function useWhatsappCallSession() { } }; - const initiateOutboundCall = async conversationId => { + // target: { conversationId } or { contactId, inboxId } + const initiateOutboundCall = async target => { // Module-scoped lock + active-session guard so a second click — from the // same composable instance OR a different one (header vs contact panel) // OR while a call is already live — can't tear down the in-flight setup @@ -320,10 +321,7 @@ export function useWhatsappCallSession() { isInitiatingOutbound.value = true; try { const sdpOffer = await prepareOutboundOffer(); - const response = await WhatsappCallsAPI.initiate( - conversationId, - sdpOffer - ); + const response = await WhatsappCallsAPI.initiate(target, sdpOffer); if (response?.id) { activeCallId = response.id; // A connect webhook that raced ahead of this response was buffered; @@ -354,7 +352,7 @@ export function useWhatsappCallSession() { data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED || data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING ) { - return { status: data.status }; + return { status: data.status, conversation_id: data.conversation_id }; } throw e; } finally { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index e707284cb..04f46890e 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -7,8 +7,7 @@ export const FEATURE_FLAGS = { AUTOMATIONS: 'automations', CAMPAIGNS: 'campaigns', WHATSAPP_CAMPAIGNS: 'whatsapp_campaign', - WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION: - 'whatsapp_embedded_signup_inbox_creation', + WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation', WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer', WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure', CANNED_RESPONSES: 'canned_responses', diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index 76a5d8bd4..8437e116f 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -127,25 +127,8 @@ export const getHostNameFromURL = url => { } }; -/** - * Extracts filename from a URL - * @param {string} url - The URL to extract filename from - * @returns {string} - The extracted filename or original URL if extraction fails - */ -export const extractFilenameFromUrl = url => { - if (!url || typeof url !== 'string') return url; - - try { - const urlObj = new URL(url); - const pathname = urlObj.pathname; - const filename = pathname.split('/').pop(); - return filename || url; - } catch (error) { - // If URL parsing fails, try to extract filename using regex - const match = url.match(/\/([^/?#]+)(?:[?#]|$)/); - return match ? match[1] : url; - } -}; +// Shared with the mobile app via @chatwoot/utils. +export { extractFilenameFromUrl } from '@chatwoot/utils'; /** * Normalizes a comma/newline separated list of domains diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js index 375e38a2d..ba056c317 100644 --- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js @@ -156,12 +156,18 @@ describe('templateHelper', () => { ]); }); - it('should handle templates with no variables', () => { + it('should handle templates with no variables but a media header', () => { const emptyTemplate = templates.find( t => t.name === 'no_variable_template' ); - const result = buildTemplateParameters(emptyTemplate, false); - expect(result).toEqual({}); + const result = buildTemplateParameters(emptyTemplate); + // hasMediaHeader is derived from the template, so the document header is kept. + expect(result.body).toBeUndefined(); + expect(result.header).toEqual({ + media_url: '', + media_type: 'document', + media_name: '', + }); }); it('should build parameters for templates with multiple component types', () => { diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js index 1fb61d760..c875871f4 100644 --- a/app/javascript/dashboard/helper/templateHelper.js +++ b/app/javascript/dashboard/helper/templateHelper.js @@ -1,19 +1,16 @@ -// Constants +import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils'; + +// Constants and pure template helpers are shared with the mobile app via +// @chatwoot/utils so the logic lives in one place. +export { + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, +} from '@chatwoot/utils'; + export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_CATEGORY = 'UTILITY'; -export const COMPONENT_TYPES = { - HEADER: 'HEADER', - BODY: 'BODY', - BUTTONS: 'BUTTONS', -}; -export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT']; - -export const findComponentByType = (template, type) => - template.components?.find(component => component.type === type); - -export const processVariable = str => { - return str.replace(/{{|}}/g, ''); -}; export const allKeysRequired = value => { const keys = Object.keys(value); @@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => { }); }; -export const buildTemplateParameters = (template, hasMediaHeaderValue) => { - const allVariables = {}; - - const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY); - const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER); - - if (!bodyComponent) return allVariables; - - const templateString = bodyComponent.text; - - // Process body variables - const matchedVariables = templateString.match(/{{([^}]+)}}/g); - if (matchedVariables) { - allVariables.body = {}; - matchedVariables.forEach(variable => { - const key = processVariable(variable); - allVariables.body[key] = ''; - }); - } - - if (hasMediaHeaderValue) { - if (!allVariables.header) allVariables.header = {}; - allVariables.header.media_url = ''; - allVariables.header.media_type = headerComponent.format.toLowerCase(); - - // For document templates, include media_name field for filename support - if (headerComponent.format.toLowerCase() === 'document') { - allVariables.header.media_name = ''; - } - } - - // Process button variables - const buttonComponents = template.components.filter( - component => component.type === COMPONENT_TYPES.BUTTONS - ); - - buttonComponents.forEach(buttonComponent => { - if (buttonComponent.buttons) { - buttonComponent.buttons.forEach((button, index) => { - // Handle URL buttons with variables - if (button.type === 'URL' && button.url && button.url.includes('{{')) { - const buttonVars = button.url.match(/{{([^}]+)}}/g) || []; - if (buttonVars.length > 0) { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'url', - parameter: '', - url: button.url, - variables: buttonVars.map(v => processVariable(v)), - }; - } - } - - // Handle copy code buttons - if (button.type === 'COPY_CODE') { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'copy_code', - parameter: '', - }; - } - }); - } - }); - - return allVariables; -}; +// The media-header flag is derived from the template inside the shared helper; +// the second argument is kept for backwards-compatible call sites. +export const buildTemplateParameters = template => + buildWhatsAppProcessedParams(template); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index fe71b8974..f3c81615b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -72,6 +72,20 @@ "RATING_TITLE": "Rating", "FEEDBACK_TITLE": "Feedback", "REPLY_MESSAGE_NOT_FOUND": "Message not available", + "CAPTAIN_GENERATION": { + "TITLE": "How was this reply generated?", + "GENERATED_BY": "Generated by Captain", + "LOADING": "Loading details…", + "EMPTY": "No generation details available for this message.", + "TIMELINE": "Generation steps", + "STEP_TOOL": "Called {name}", + "STEP_HANDOFF": "Handed off to {name}", + "REASONING": "Reasoning", + "SOURCES": "Knowledge base", + "SOURCES_SUMMARY": "{count} result | {count} results", + "MODEL": "Generated with {model}", + "CREDITS": "Credits: {credits}" + }, "CARD": { "SHOW_LABELS": "Show labels", "HIDE_LABELS": "Hide labels", diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue index 80f8e08c5..553cbceac 100644 --- a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue @@ -1,5 +1,6 @@