diff --git a/.env.example b/.env.example index bc7380a29..8a4f0bb5d 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= +# Maximum time in seconds to process a single IMAP email +# EMAIL_PROCESSING_TIMEOUT_SECONDS=60 # Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail diff --git a/Gemfile b/Gemfile index b27b66fde..e10984f53 100644 --- a/Gemfile +++ b/Gemfile @@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index ed1d94172..4da0e5847 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -339,6 +339,7 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + firecrawl-sdk (1.4.1) flag_shih_tzu (0.3.23) foreman (0.87.2) fugit (1.11.1) @@ -1079,6 +1080,7 @@ DEPENDENCIES faker faraday_middleware-aws-sigv4 fcm + firecrawl-sdk (~> 1.0) flag_shih_tzu foreman gemoji diff --git a/app/controllers/api/v1/accounts/working_hours_controller.rb b/app/controllers/api/v1/accounts/working_hours_controller.rb deleted file mode 100644 index 96d98293a..000000000 --- a/app/controllers/api/v1/accounts/working_hours_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController - before_action :check_authorization - before_action :fetch_webhook, only: [:update] - - def update - @working_hour.update!(working_hour_params) - end - - private - - def working_hour_params - params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day) - end - - def fetch_working_hour - @working_hour = Current.account.working_hours.find(params[:id]) - end -end diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..c39a4cf9d 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -35,8 +35,9 @@ class ContactAPI extends ApiClient { return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); } - getConversations(contactId) { - return axios.get(`${this.url}/${contactId}/conversations`); + getConversations(contactId, { inboxId } = {}) { + const params = inboxId ? { inbox_id: inboxId } : {}; + return axios.get(`${this.url}/${contactId}/conversations`, { params }); } getContactableInboxes(contactId) { @@ -47,9 +48,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..7e2b6f0c4 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; +import { + isVoiceCallEnabled, + getVoiceCallProvider, + VOICE_CALL_PROVIDERS, +} from 'dashboard/helper/inbox'; +import { + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, +} from 'dashboard/components-next/message/constants'; 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'; @@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; const props = defineProps({ phone: { type: String, default: '' }, contactId: { type: [String, Number], required: true }, + // When set, the WhatsApp call continues in this conversation (matching the + // header button) instead of looking up the contact's most recent one. + conversationId: { type: [String, Number], default: null }, label: { type: String, default: '' }, icon: { type: [String, Object, Function], default: '' }, size: { type: String, default: 'sm' }, @@ -30,6 +43,7 @@ const { t } = useI18n(); const dialogRef = ref(null); +const callsStore = useCallsStore(); const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); @@ -38,13 +52,22 @@ const voiceInboxes = computed(() => ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); -// Unified behavior: hide when no phone const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const isInitiatingCall = computed(() => { return contactsUiFlags.value?.isInitiatingCall || false; }); +// Mirror the conversation-header button: block a new call whenever any provider +// call is already active or ringing, otherwise starting a WhatsApp call here +// would leave a still-live Twilio (or other) session with no visible control. +const isCallButtonDisabled = computed( + () => + callsStore.hasActiveCall || + callsStore.hasIncomingCall || + isInitiatingCall.value +); + const navigateToConversation = conversationId => { const accountId = route.params.accountId; if (conversationId && accountId) { @@ -58,23 +81,96 @@ const navigateToConversation = conversationId => { } }; -const startCall = async inboxId => { - if (isInitiatingCall.value) return; +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); + // 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; + 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 + // claiming the call started. The permission message lands in the + // conversation, so still navigate there. + const messageKey = + response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING' + : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED'; + useAlert(t(messageKey)); + navigateToConversation(conversationId); + return; + } + + // Stay non-active until the connect cable event arrives — flipping to active + // here would start the duration timer before the contact picks up. + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId, + inboxId, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); + navigateToConversation(conversationId); +}; + +const startCall = async (inboxId, conversationIdHint = null) => { + if (isCallButtonDisabled.value) return; + + const inbox = (inboxesList.value || []).find(i => i.id === inboxId); + if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) { + try { + await startWhatsappCall(inboxId, conversationIdHint); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } + return; + } try { const response = await store.dispatch('contacts/initiateCall', { contactId: props.contactId, inboxId, + conversationId: conversationIdHint, }); const { call_sid: callSid, conversation_id: conversationId } = response; - // Add call to store immediately so widget shows - const callsStore = useCallsStore(); callsStore.addCall({ callSid, conversationId, inboxId, - callDirection: 'outbound', + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); @@ -86,6 +182,22 @@ const startCall = async inboxId => { }; const onClick = async () => { + // In conversation context, only stay in this conversation if its inbox is + // itself voice-capable (works the same for Twilio and WhatsApp). For + // non-voice channels (email, web, …) fall back to the picker so the call + // goes out via a voice inbox. + if (props.conversationId) { + const conversation = store.getters.getConversationById( + props.conversationId + ); + const conversationInbox = (inboxesList.value || []).find( + i => i.id === conversation?.inbox_id + ); + if (conversationInbox && isVoiceCallEnabled(conversationInbox)) { + await startCall(conversationInbox.id, props.conversationId); + return; + } + } if (voiceInboxes.value.length > 1) { dialogRef.value?.open(); return; @@ -106,7 +218,7 @@ const onPickInbox = async inbox => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" - :disabled="isInitiatingCall" + :disabled="isCallButtonDisabled" :is-loading="isInitiatingCall" :label="label" :icon="icon" diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 847bbd600..b12d0331a 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -142,29 +142,27 @@ watch( diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 831312e0b..59c710a37 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -145,45 +145,43 @@ const handleCreateArticle = event => { diff --git a/app/javascript/dashboard/components/IntersectionObserver.vue b/app/javascript/dashboard/components/IntersectionObserver.vue index c650a8c0e..36135bd44 100644 --- a/app/javascript/dashboard/components/IntersectionObserver.vue +++ b/app/javascript/dashboard/components/IntersectionObserver.vue @@ -1,5 +1,5 @@ - - diff --git a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue index 924e72c53..b0428ecdb 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue @@ -1,6 +1,6 @@ + +