Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 22fd5a872b feat(whatsapp-call): direct browser-Meta WebRTC, no media server 2026-04-29 15:23:32 +07:00
Muhsin KelothandGitHub fa582b31fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-28 11:00:45 +04:00
Muhsin KelothandGitHub 8aa71f4a9d Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-28 09:43:04 +04:00
Muhsin KelothandGitHub 223e6402d8 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-23 11:32:13 +04:00
Muhsin KelothandGitHub e6138739ad Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-21 13:23:15 +04:00
Muhsin KelothandGitHub 6d6133d5c9 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-21 13:23:06 +04:00
Muhsin KelothandGitHub 6cf5f323fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 20:28:13 +04:00
Muhsin KelothandGitHub 85f522c693 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 20:28:02 +04:00
Muhsin KelothandGitHub 17a17ad00c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 15:26:25 +04:00
Muhsin KelothandGitHub d365a5fe15 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 15:26:12 +04:00
Muhsin 98ba6e5200 refactor(voice): drop content_attributes.data from voice_call messages
Nothing reads it anymore — frontend uses message.call, CallMessageBuilder
uses call.message (the FK), and the jsonb fallback finder was dead code
(call.message_id is always linked immediately after message creation, in
the same transaction).

New voice_call messages are now created with blank content_attributes.
Existing rows keep their legacy payload; nothing reads it, so it's inert.
Safe to backfill with `Message.voice_calls.update_all(content_attributes: {})`
if you want a tidy table.
2026-04-20 14:38:57 +04:00
Muhsin 02f1fdf3b6 fix(voice): avoid Jbuilder's reserved call keyword in message serializer
Jbuilder defines Jbuilder#call(object, *attributes) as its core API for
extracting attributes. Using `json.call do ... end` collided with that
signature and raised "wrong number of arguments (given 0, expected 1+)".

Use `json.set! :call, message.call.push_event_data` instead — also
deduplicates the field list between the jbuilder and Message#push_event_data
(both now go through Call#push_event_data).
2026-04-20 14:30:42 +04:00
Muhsin c7b2a6f12c refactor(voice): stop duplicating call state in message.content_attributes
Now that the frontend reads from message.call (previous commit), the
message row is just a display anchor — all call state lives on the Call
record and is surfaced via the serializer / push_event_data.

- CallMessageBuilder shrinks to creating the bare voice_call message.
  content_attributes.data keeps only { call_sid } as a breadcrumb and a
  finder fallback for the edge case where message_id isn't linked yet.
- CallStatus::Manager stops re-writing the message on status transitions.
  call.message.touch triggers the standard message.updated dispatcher so
  sockets re-broadcast with the fresh Call embedded.
- Spec assertions switch from content_attributes.data.* to call.* +
  message.call object identity; status update spec asserts message is
  touched.
2026-04-20 13:42:11 +04:00
Muhsin c2a851c756 refactor(voice): read call state from message.call on the frontend
- VoiceCall bubble, voice.js helper, ConversationCard, and the store
  mutation all switch from message.content_attributes.data to message.call.
- Message.vue gains a `call` prop that's provided through
  useMessageContext for the bubble.
- UPDATE_MESSAGE_CALL_STATUS mutation now updates message.call.status,
  matched by provider_call_id.
- content_attributes.data is still emitted by the backend for this
  commit; backend-side trim lands in a follow-up.
2026-04-20 13:36:26 +04:00
Muhsin 5ce77eedbe feat(voice): embed Call in voice_call message payload
Adds a nested `call` object to the voice_call message JSON / push
payload so the frontend can read call state directly from message.call
instead of duplicating it in content_attributes.data.

- Message gains has_one :call (inverse of existing Call#belongs_to :message)
  via enterprise module prepend.
- Message serializer (_message.json.jbuilder) emits a call block for
  voice_call messages when the Call is linked.
- Message#push_event_data mirrors the payload into socket broadcasts.
- MessageFinder eager-loads the call (+ contact + inbox.channel) to
  avoid N+1 when listing conversation messages.
- Call gains helper methods: display_status, from_number, to_number,
  recording_url, push_event_data. These centralize the direction- and
  status-aware derivations so jbuilder and push_event_data stay in sync.

Frontend continues to read content_attributes.data — this commit only
adds the new shape; a follow-up swaps readers to message.call.
2026-04-20 13:33:43 +04:00
Muhsin KelothandGitHub 811d50043c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 12:27:32 +04:00
Muhsin KelothandGitHub 1a2e518341 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 12:25:00 +04:00
Muhsin 117742a779 fix(voice): guarantee conference_sid at TwiML render, consistent Call factory
- conference_twiml now ensures call.conference_sid is present before
  rendering; backfills via Voice::Conference::Name.for(call) if missing.
  Prevents invalid TwiML on Call rows created without the sid set.
- Call factory derives account/inbox/contact from the associated
  conversation so create(:call) is always internally consistent (no more
  mismatched account_id across associations).
2026-04-18 10:37:47 +04:00
Muhsin 081e578a57 fix(voice): validate conversation_id when resolving Call in ConferenceController
Require the resolved Call to belong to the passed conversation_id so a
request with mismatched (conversation_id, call_sid) fails instead of
silently operating on the call from call_sid. Restores the implicit
authorization the previous conversation-based lookup provided.
2026-04-17 23:38:40 +04:00
Muhsin 7635a538af fix(voice): harden agent-label parsing and prevent started_at drift
- Conference::Manager.extract_user_id now requires the account id in the
  participant label to match call.account_id, so a spoofed label can't
  attach a user from another account to the call.
- CallStatus::Manager keeps the earliest started_at across multiple
  in-progress webhooks (Twilio can emit answered + in-progress, plus
  retries) so duration_seconds isn't undercounted.
2026-04-17 23:02:28 +04:00
Muhsin d6d6f87145 fix(voice): O(1) voice_call message lookup and nil-safe content_attributes
- find_message prefers call.message (via the already-linked message_id)
  instead of scanning every voice_call message in the conversation. Falls
  back to a jsonb-indexed query by call_sid when message_id isn't set yet.
- update_message! tolerates a nil content_attributes column (nullable in
  the schema).
2026-04-17 22:52:13 +04:00
Muhsin d62037676c fix(voice): scope all VoiceController Call lookups to the inbox
Every Call lookup in the Twilio webhook controller is now constrained
to the inbox resolved from the URL phone number. Prevents tampered
params from attaching the agent leg or outbound leg to a Call belonging
to another inbox/account.

Factored out the common `inbox_calls` scope used by
find_call_for_agent, sync_outbound_leg, and find_call_for_conference!.
2026-04-17 22:49:46 +04:00
Muhsin 91da28a329 fix(voice): scope inbound Call lookup and handle create races
- Scope the existing-call pre-check to the inbound account + inbox.
- Rescue ActiveRecord::RecordNotUnique so a concurrent Twilio retry
  doesn't 500 when two webhooks race to create the same Call.
2026-04-17 22:45:39 +04:00
Muhsin 4282dec7d6 fix(voice): thread callSid through agent leg + conference API clients
Required by the stricter server-side call_sid requirements from the
previous commit. Without this, agents would fail to join or leave calls
because the client side still sent only conversation_id.

- TwilioVoiceClient.joinClientCall now forwards callSid as a Device.connect
  param, which becomes params[:call_sid] on the TwiML webhook.
- VoiceAPI.leaveConference takes callSid and passes it as a query param
  so the DELETE /conference endpoint can resolve the exact call.
- useCallSession and FloatingCallWidget pass callSid through.
2026-04-17 22:41:20 +04:00
Muhsin 12dc22b693 fix(voice): address codex review feedback on Call-model PR
- Guard against delayed conference-start retries rolling a progressed
  call back to ringing (Conference::Manager#mark_ringing!).
- Scope conference-status webhook Call lookup to the resolved inbox
  (VoiceController#find_call_for_conference!).
- Require explicit call_sid on the agent TwiML leg and on
  ConferenceController#create/destroy so multi-call conversations can't
  attach the wrong call (no more most-recent-active fallback).
- Scope StatusUpdateService Call lookup to the webhook's account for
  defense-in-depth.
- Update UPDATE_MESSAGE_CALL_STATUS mutation to match the voice_call
  message by call_sid; helper/voice.js now passes callSid. Prevents an
  older call's status update from clobbering the newest bubble.
2026-04-17 22:28:10 +04:00
Muhsin 07de9ae1f1 test(voice): update specs to match Call-model-backed voice flow
- Add calls factory.
- voice_controller_spec, conference_controller_spec, and the voice
  service specs now assert against Call records instead of
  conversation.identifier / additional_attributes.
- Scope Call lookup in ConferenceController to the authorized inbox so
  call_sid alone can't cross-inbox escalate.
2026-04-17 20:24:37 +04:00
Muhsin 47788f4d9c Delete voice_call_wiring.md 2026-04-17 19:47:38 +04:00
Muhsin bb4feec53e feat(voice): wire Twilio voice flow through unified Call model
Moves call state off conversation.additional_attributes and
conversation.identifier onto first-class Call records, one per call.

- Call is the source of truth for status, direction, duration, started_at,
  accepted_by_agent, and conference_sid (in meta).
- Conference names key off Call.id (conf_account_{aid}_call_{cid}), so
  multiple calls on one conversation no longer collide. lock_to_single_conversation
  inboxes append each call as a new voice_call bubble in the existing thread.
- Agent identity on conference join webhooks is parsed from the Twilio
  ParticipantLabel (agent-{user_id}-account-{account_id}); stateless and
  independent of the /conference#create API call order.
- ConversationCard.vue derives the call badge from the latest voice_call
  message, not a denormalized cache on the conversation. Removes the stale
  "Call ended" state that lingered after subsequent text messages.
2026-04-17 19:44:11 +04:00
Muhsin KelothandGitHub 3b89787c7f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-17 12:16:55 +04:00
Muhsin 94766e1200 chore: rescue errors only in delete_twiml_app 2026-04-14 15:29:16 +04:00
Muhsin 5a16a5a0ee test: stub incoming_phone_numbers in voice teardown spec
Teardown now also clears number-level voice_url/status_callback, so
the Twilio client double needs incoming_phone_numbers.list stubbed.
2026-04-14 15:24:44 +04:00
Muhsin b8df28c8a3 chore: restrict TwilioSms attrs to SMS medium 2026-04-14 14:36:44 +04:00
Muhsin a77a1c8ab4 chore: skip TwiML provisioning if no phone number 2026-04-14 14:31:04 +04:00
Muhsin 57821ab6f9 chore: clear Twilio number webhooks during teardown 2026-04-14 14:30:23 +04:00
Muhsin KelothandGitHub b4e27ed4cb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-14 14:10:12 +04:00
Muhsin KelothandGitHub 726193e3ac Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:45:18 +04:00
Muhsin KelothandGitHub 55fd3c62cc Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:16:05 +04:00
Muhsin KelothandGitHub 1a63c4089a Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-11 10:51:31 +04:00
Muhsin KelothandGitHub b98ce4b18c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 20:06:48 +04:00
Muhsin ddbd81b85e Update voice_teardown_service.rb 2026-04-08 13:32:14 +04:00
Muhsin 986f3ebc3a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:22:16 +04:00
Muhsin a6a612568a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:21:00 +04:00
Muhsin KelothandGitHub 7ebe9db77c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:18:34 +04:00
Muhsin KelothandGitHub fdf22a034f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:09:59 +04:00
Muhsin KelothandGitHub 14100580fb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 16:46:02 +04:00
Muhsin KelothandGitHub 453fcecfb4 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 15:45:18 +04:00
Muhsin KelothandGitHub 401ec4cd81 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 14:04:33 +04:00
Muhsin 38867ba75f chore: use API key client for token validation 2026-04-06 13:27:52 +04:00
Muhsin d3b7d7e79a chore: show voice config only if inbox webhook set 2026-04-06 12:29:51 +04:00
Muhsin cb275ce1d9 chore: remove voiceCall gating for tabs and settings 2026-04-06 11:29:48 +04:00
Muhsin e96fa6f561 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-06 11:27:24 +04:00
Muhsin 78296b9ce3 chore: require voice_call content for Twilio incoming 2026-04-06 11:27:16 +04:00
Muhsin KelothandGitHub 9c85a92e86 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 11:14:24 +04:00
Muhsin KelothandGitHub f7d4d2d58c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-04 17:48:37 +04:00
Muhsin KelothandGitHub fdce5910e0 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 16:10:34 +04:00
Muhsin aa15ddfef8 chore: enable channel_voice in inboxes spec 2026-04-01 15:38:58 +04:00
Muhsin d80a7454ce chore: run teardown_voice callback only on update 2026-04-01 15:28:08 +04:00
Muhsin b684f75631 Update twilio_sms_voice_spec.rb 2026-04-01 15:04:53 +04:00
Muhsin 6e0b10cb42 chore: expose API key secret and require it in UI 2026-04-01 15:01:54 +04:00
Muhsin 020bf6af93 chore: don't provision Twiml app if sid present 2026-04-01 14:58:50 +04:00
Muhsin a51af8ca68 chore: fix review comments 2026-04-01 14:28:11 +04:00
Muhsin dfdfc669ea chore: reserve api_key_secret and update voice UI 2026-04-01 14:22:50 +04:00
Muhsin 987d4d4a01 chore: revert unrelated changes 2026-04-01 13:04:49 +04:00
Muhsin 3471296394 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-01 13:01:57 +04:00
Muhsin 465b70ce27 chore: revert unrelated changes 2026-04-01 13:01:46 +04:00
Muhsin KelothandGitHub c90fdd6594 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 12:41:07 +04:00
Muhsin 6890293e60 chore: extract Twilio voice teardown into service 2026-03-28 12:39:02 +04:00
Muhsin 5c704691d7 chore: validate Twilio number voice capability 2026-03-27 15:01:45 +04:00
Muhsin 1946c373d7 chore: teardown Twilio voice resources when disabled 2026-03-27 14:16:17 +04:00
Muhsin 797d7790db chore: add feature flags 2026-03-27 13:43:30 +04:00
Muhsin 1d555cebfc chore: handle Twilio API Key SID conditionally 2026-03-27 13:37:04 +04:00
Muhsin 2b5d3300f3 chore: add Voice configuration UI and API fields 2026-03-26 18:28:29 +04:00
Muhsin ddfa6cea32 chore: fix the specs 2026-03-26 17:15:52 +04:00
Muhsin 85438cf58a chore: add Twilio voice support to TwilioSms channel 2026-03-26 16:40:33 +04:00
228 changed files with 2709 additions and 8810 deletions
-3
View File
@@ -11,9 +11,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deployment_check:
name: Check Deployment
-3
View File
@@ -8,9 +8,6 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
-3
View File
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
-3
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
-3
View File
@@ -7,9 +7,6 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
-1
View File
@@ -22,7 +22,6 @@ gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+3 -5
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -349,7 +349,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -677,7 +676,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
@@ -1076,7 +1075,6 @@ DEPENDENCIES
fcm
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
+1 -8
View File
@@ -80,17 +80,10 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
ALLOWED_LOGIN_METHODS: allowed_login_methods
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -1,9 +0,0 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -1,20 +0,0 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
+2 -6
View File
@@ -7,15 +7,11 @@ class UserDrop < BaseDrop
@obj.try(:available_name)
end
def email
@obj.try(:email)
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
end
+3 -3
View File
@@ -11,7 +11,9 @@ class MessageFinder
private
def conversation_messages
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
end
def messages
@@ -48,5 +50,3 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse
end
end
MessageFinder.prepend_mod_with('MessageFinder')
+1 -1
View File
@@ -7,7 +7,7 @@ module EmailHelper
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
-3
View File
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -28,7 +27,6 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
StatusBanner,
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
@@ -139,7 +137,6 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -1,45 +0,0 @@
/* 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();
@@ -0,0 +1,68 @@
/* global axios */
import ApiClient from './ApiClient';
class VoiceCallsAPI extends ApiClient {
constructor() {
super('voice_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`);
}
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
// it to Meta via pre_accept_call+accept_call.
accept(callId, { sdpAnswer } = {}) {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`);
}
// Outbound: browser builds the offer first; Rails ships it to Meta and
// creates the Call record. Meta delivers its SDP answer later via the
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
initiate(conversationId, provider, { sdpOffer } = {}) {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
provider,
sdp_offer: sdpOffer,
});
}
// Get the current agent's active call (if any). Used on page load to detect
// a stale active session so we can terminate it cleanly.
active() {
return axios.get(`${this.url}/active`);
}
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
// controller attaches it to the call's voice_call message; the after-create
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
uploadRecording(callId, blob, filename) {
const fd = new FormData();
fd.append('recording', blob, filename);
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
// needs the absolute account-scoped path).
uploadRecordingUrl(callId) {
return `${this.url}/${callId}/upload_recording`;
}
terminateUrl(callId) {
return `${this.url}/${callId}/terminate`;
}
}
export default new VoiceCallsAPI();
@@ -51,6 +51,7 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -3,16 +3,10 @@ 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,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
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 { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -40,7 +34,9 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(isVoiceCallEnabled)
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
@@ -63,76 +59,24 @@ 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).
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId);
const conversations = data?.payload || [];
const match = conversations
.filter(c => c.inbox_id === inboxId)
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
return match?.id || null;
};
const startWhatsappCall = async inboxId => {
const conversationId = await findWhatsappConversationId(inboxId);
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
if (!response?.id) {
// Permission flow returns no id — banner already handled server-side; surface to user.
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.setActiveCall({
id: response.call_id,
callId: response.call_sid,
provider: response.provider || 'twilio',
direction: 'outbound',
status: 'ringing',
conversationId: response.conversation_id,
caller: null,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -35,16 +35,10 @@ const emit = defineEmits([
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
@@ -81,9 +81,13 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const voiceCallEnabled = computed(() => props.voiceEnabled);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
props.hasSelectedInbox &&
isRegularMessageMode.value &&
!voiceCallEnabled.value
);
});
@@ -108,7 +112,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !props.voiceEnabled) setSignature();
if (newValue && !voiceCallEnabled.value) setSignature();
});
},
{ immediate: true }
@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -18,13 +20,11 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate:
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
amber:
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -10,22 +10,22 @@ defineProps({
<template>
<div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
<div class="flex flex-col gap-2 pt-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="text-sm prose prose-bubble"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="flex justify-center items-center p-3">
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="flex justify-center items-center p-3">
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -8,7 +8,7 @@ defineProps({
</script>
<template>
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -1,21 +1,24 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { formatDuration } from 'shared/helpers/timeHelper';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,65 +33,64 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { t } = useI18n();
const store = useStore();
const {
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
} = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
const router = useRouter();
const { contentAttributes, messageType, attachments } = useMessageContext();
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
// applies useCamelCase(messages, { deep: true }) before rendering.
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
// Call source and metadata — all camelCase due to deep transform
const callSource = computed(() => data.value?.callSource);
const isVoiceCall = computed(() =>
['whatsapp', 'twilio'].includes(callSource.value)
);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
// Recording and transcript live on the first audio attachment. After the
// call ends, CallRecordingFetchJob creates an Attachment on this message;
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
const audioAttachment = computed(
() => attachments.value?.find(a => a.fileType === 'audio') || null
);
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
const transcript = computed(() => audioAttachment.value?.transcribedText);
const isJoining = ref(false);
const isRejecting = ref(false);
const showTranscript = ref(false);
// Reject is shown only for ringing inbound calls — outbound calls and
// in-progress calls don't need a reject button (initiator hangs up via the
// floating widget).
const showRejectButton = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === 'audio')
isVoiceCall.value &&
!isOutbound.value &&
status.value === VOICE_CALL_STATUS.RINGING
);
// Duration may arrive on call.duration_seconds (push_event_data) or
// content_attributes.data.duration_seconds — and either may be camelCased
// upstream, so check every variant.
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
const formattedDuration = computed(() => {
const seconds = durationSeconds.value;
if (!seconds || seconds <= 0) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
});
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
// Show join/accept button logic
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
// DTLS fingerprint), so only ringing calls show the Accept button.
const showJoinButton = computed(
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
@@ -102,28 +104,35 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
const subtextKey = computed(() => {
// acceptedBy on outbound calls is the initiator, not the contact — so keep
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
// should suppress the subtext in favor of the acceptedBy line.
if (
!isOutbound.value &&
acceptedBy.value?.name &&
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
status.value
)
) {
return null;
}
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const answeredByText = computed(() => {
if (isOutbound.value) return '';
if (!acceptedBy.value?.name) return '';
return acceptedBy.value.name;
});
const iconName = computed(() => {
@@ -133,61 +142,42 @@ const iconName = computed(() => {
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.providerCallId);
// Show "Join call" when the call is still ringing, no agent has claimed it,
// and the conversation is unassigned or assigned to the current user. Mirrors
// the eligibility used by FloatingCallWidget so the bubble can act as a
// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
// Suppress the button once this call is the local active session — the
// message status webhook may lag behind, so we can't rely on `status` alone
// to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
if (assignee?.id && assignee.id !== currentUserId.value) return false;
return true;
});
const recordingAttachment = computed(() => {
if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
fileType: 'audio',
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
});
const handleJoinCall = async () => {
if (!canJoinCall.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeCall.value.inboxId,
callSid: activeCall.value.callSid,
});
if (isJoining.value || !isVoiceCall.value) return;
isJoining.value = true;
try {
const result = await acceptVoiceCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: result.call.conversationId },
});
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Accept from bubble failed:', err);
} finally {
isJoining.value = false;
}
};
await joinCall({
conversationId: conversationId.value,
inboxId: inboxId.value,
callSid: callSid.value,
});
const handleRejectCall = async () => {
if (isRejecting.value || !callId.value) return;
isRejecting.value = true;
try {
await VoiceCallsAPI.reject(callId.value);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Reject from bubble failed:', err);
} finally {
isRejecting.value = false;
}
};
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-sm">
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -203,27 +193,90 @@ const handleJoinCall = async () => {
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span v-if="subtext" class="text-xs text-n-slate-11">
{{ subtext }}
<span v-if="answeredByText" class="text-xs text-n-slate-11">
{{
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
name: answeredByText,
})
}}
</span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
class="text-xs text-n-slate-10"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
{{ formattedDuration }}
</span>
</div>
<button
v-if="showRejectButton"
:disabled="isRejecting"
:title="$t('WHATSAPP_CALL.REJECT')"
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isRejecting }"
@click="handleRejectCall"
>
<i
v-if="isRejecting"
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
/>
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
</button>
<button
v-if="showJoinButton"
:disabled="isJoining"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isJoining }"
@click="handleJoinCall"
>
<i
v-if="isJoining"
class="i-ph-circle-notch-bold text-sm animate-spin"
/>
<i v-else class="i-ph-phone-bold text-sm" />
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
</button>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
<div
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-2"
>
<audio controls class="w-full h-8" :src="recordingUrl">
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-3"
>
<button
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
@click="showTranscript = !showTranscript"
>
<i
class="text-sm"
:class="
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
"
/>
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
</button>
<p
v-if="showTranscript"
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
>
{{ transcript }}
</p>
</div>
</div>
</BaseBubble>
@@ -41,33 +41,8 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
@@ -78,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,33 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-if="message.buttons && message.buttons.length > 0"
class="flex flex-col gap-2"
>
<Button
v-for="(button, index) in message.buttons"
:key="index"
:label="button.text || button.title || 'Button'"
slate
class="!text-n-blue-11 w-full"
/>
</div>
</div>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
const DOC_EXTENSIONS = ['.doc'];
const mediaType = computed(() => {
if (props.message.mediaType) return props.message.mediaType;
const format = props.message.header?.format;
if (format) return format.toLowerCase();
const url = props.message.image_url || '';
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
return 'image';
});
const fileType = computed(() => {
const url = props.message.image_url || '';
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
});
</script>
<template>
<div
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<img
v-if="mediaType === 'image'"
:src="message.image_url"
class="object-cover w-full max-h-44 rounded-lg"
alt="Template media"
/>
<div
v-else-if="mediaType === 'video'"
class="overflow-hidden relative rounded-lg"
>
<video
:src="message.image_url"
class="object-cover w-full max-h-44"
controls
preload="metadata"
/>
</div>
<div v-else-if="mediaType === 'document'" class="flex items-center">
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
</div>
<span
v-if="message.content"
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const actions = computed(() => props.message.actions || []);
</script>
<template>
<div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-for="(action, index) in actions"
:key="index"
class="flex justify-center items-center p-3"
>
<Button
:label="action.title || action.text || 'Button'"
link
class="hover:!no-underline"
>
<template #icon>
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
</template>
</Button>
</div>
</div>
</template>
@@ -1,105 +0,0 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
const greet = findWhatsApp('greet');
const eventInvitation = findWhatsApp('event_invitation_static');
const orderConfirmation = findWhatsApp('order_confirmation');
const discountCoupon = findWhatsApp('discount_coupon');
const trainingVideo = findWhatsApp('training_video');
const twilioGreet = findTwilio('greet');
const shoeLaunch = findTwilio('shoe_launch');
const welcomeMessage = findTwilio('welcome_message_new');
const courseFeeReminder = findTwilio('course_fee_reminder');
</script>
<template>
<Story
title="Components/TemplatePreview"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WhatsApp - Simple Text">
<TemplatePreview
:template="greet"
:variables="getWhatsAppVariables(greet)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Simple Text (No Variables)">
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
</Variant>
<Variant title="WhatsApp - Call to Action Buttons">
<TemplatePreview
:template="eventInvitation"
:variables="getWhatsAppVariables(eventInvitation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Image Media">
<TemplatePreview
:template="orderConfirmation"
:variables="getWhatsAppVariables(orderConfirmation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Video Media">
<TemplatePreview
:template="trainingVideo"
:variables="getWhatsAppVariables(trainingVideo)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Copy Code">
<TemplatePreview
:template="discountCoupon"
:variables="getWhatsAppVariables(discountCoupon)"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio - Text">
<TemplatePreview
:template="twilioGreet"
:variables="twilioGreet.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Media">
<TemplatePreview
:template="shoeLaunch"
:variables="shoeLaunch.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Quick Reply">
<TemplatePreview
:template="welcomeMessage"
:variables="welcomeMessage.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Call to Action">
<TemplatePreview
:template="courseFeeReminder"
:variables="courseFeeReminder.variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -1,112 +0,0 @@
<script setup>
import { computed } from 'vue';
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
import {
PLATFORMS,
TEMPLATE_TYPES,
WA_HEADER_FORMATS,
WA_MEDIA_FORMATS,
} from 'dashboard/services/TemplateConstants';
import CardTemplate from './CardTemplate.vue';
import CallToActionTemplate from './CallToActionTemplate.vue';
import MediaTemplate from './MediaTemplate.vue';
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
import QuickReplyTemplate from './QuickReplyTemplate.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
variables: {
type: Object,
default: () => ({}),
},
platform: {
type: String,
required: true,
validator: value => Object.values(PLATFORMS).includes(value),
},
});
const COMPONENT_MAP = {
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
};
const substituteVariables = (text, variables) => {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
const value = variables[variable];
return value !== undefined && value !== '' ? value : `[${variable}]`;
});
};
const processedTemplate = computed(() => {
const normalized = TemplateNormalizer.normalize(
props.template,
props.platform
);
let content = '';
let imageUrl = '';
let title = '';
let footer = '';
if (props.platform === PLATFORMS.WHATSAPP) {
content = normalized.body?.text || '';
if (normalized.header) {
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
imageUrl = normalized.header.example?.header_handle?.[0] || '';
}
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
title = normalized.header.text || '';
}
}
footer = normalized.footer?.text || '';
} else {
content = normalized.body || '';
if (normalized.media && normalized.media.length > 0) {
imageUrl = normalized.media[0];
}
}
const buttons = normalized.buttons?.length
? normalized.buttons
: normalized.actions || [];
return {
...normalized,
content: substituteVariables(content, props.variables),
title: substituteVariables(title, props.variables),
footer: substituteVariables(footer, props.variables),
image_url: substituteVariables(imageUrl, props.variables),
buttons,
actions: normalized.actions || [],
};
});
const previewComponent = computed(
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
);
</script>
<template>
<div class="template-preview">
<component :is="previewComponent" :message="processedTemplate" />
</div>
</template>
@@ -1,199 +0,0 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
</script>
<template>
<Story
title="Components/TemplatePreviewExamples"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WA: Event Invitation (Buttons)">
<TemplatePreview
:template="whatsAppTemplates[0]"
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Purchase Receipt (Document)">
<TemplatePreview
:template="whatsAppTemplates[1]"
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Discount Coupon (Copy Code)">
<TemplatePreview
:template="whatsAppTemplates[2]"
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Support Callback (Phone)">
<TemplatePreview
:template="whatsAppTemplates[3]"
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Training Video (Video)">
<TemplatePreview
:template="whatsAppTemplates[4]"
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Order Confirmation (Image)">
<TemplatePreview
:template="whatsAppTemplates[5]"
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Product Launch (Image + Footer)">
<TemplatePreview
:template="whatsAppTemplates[6]"
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Technician Visit (Header + Buttons)">
<TemplatePreview
:template="whatsAppTemplates[7]"
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Greet (Simple Text)">
<TemplatePreview
:template="whatsAppTemplates[8]"
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Hello World (Header + Footer)">
<TemplatePreview
:template="whatsAppTemplates[9]"
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Feedback Request (Button)">
<TemplatePreview
:template="whatsAppTemplates[10]"
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Address Update (Header)">
<TemplatePreview
:template="whatsAppTemplates[11]"
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Delivery Confirmation">
<TemplatePreview
:template="whatsAppTemplates[12]"
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio: Shoe Launch (Media)">
<TemplatePreview
:template="twilioTemplates[0]"
:variables="twilioTemplates[0].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch Custom Price (Media)">
<TemplatePreview
:template="twilioTemplates[1]"
:variables="twilioTemplates[1].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch (Media)">
<TemplatePreview
:template="twilioTemplates[2]"
:variables="twilioTemplates[2].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Greet (Text)">
<TemplatePreview
:template="twilioTemplates[3]"
:variables="twilioTemplates[3].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Order Status (Text)">
<TemplatePreview
:template="twilioTemplates[4]"
:variables="twilioTemplates[4].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Hello World (Text)">
<TemplatePreview
:template="twilioTemplates[5]"
:variables="twilioTemplates[5].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message New (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[6]"
:variables="twilioTemplates[6].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[7]"
:variables="twilioTemplates[7].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[8]"
:variables="twilioTemplates[8].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
<TemplatePreview
:template="twilioTemplates[9]"
:variables="twilioTemplates[9].variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -1,26 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<div v-if="message.title" class="text-base font-bold">
{{ message.title }}
</div>
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
<span v-dompurify-html="message.content" />
</div>
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
{{ message.footer }}
</div>
</div>
</template>
@@ -1,6 +0,0 @@
// Main Template Preview Component
export { default as TemplatePreview } from './TemplatePreview.vue';
// Core Services
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
@@ -1,209 +0,0 @@
export const twilioTemplates = [
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '100$',
},
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
friendly_name: 'shoe_launch',
template_type: 'media',
},
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '400$',
},
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
friendly_name: 'product_launch_custom_price',
template_type: 'media',
},
{
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
types: {
'twilio/media': {
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
friendly_name: 'product_launch',
template_type: 'media',
},
{
body: 'Hey {{1}}, how may I help you?',
types: {
'twilio/text': {
body: 'Hey {{1}}, how may I help you?',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
},
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
friendly_name: 'greet',
template_type: 'text',
},
{
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
types: {
'twilio/text': {
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
3: '12345',
},
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
friendly_name: 'order_status',
template_type: 'text',
},
{
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
types: {
'twilio/text': {
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
friendly_name: 'hello_world',
template_type: 'text',
},
{
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
actions: [
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Support_payload',
title: 'Support',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
friendly_name: 'welcome_message_new',
template_type: 'quick_reply',
},
{
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
types: {
'twilio/quick-reply': {
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
actions: [
{
id: 'Chatwoot Cloud_payload',
title: 'Chatwoot Cloud',
},
{
id: 'Self-hosted Chatwoot_payload',
title: 'Self-hosted Chatwoot',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
friendly_name: 'saas_whatsapp_question',
template_type: 'quick_reply',
},
{
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
actions: [
{
id: 'Support_payload',
title: 'Support',
},
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Demo_payload',
title: 'Demo',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
friendly_name: 'welcome_message',
template_type: 'quick_reply',
},
{
types: {
'twilio/call-to-action': {
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
friendly_name: 'course_fee_reminder',
template_type: 'call_to_action',
},
];
@@ -1,408 +0,0 @@
export const whatsAppTemplates = [
{
id: '1381151706284063',
name: 'event_invitation_static',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'F1',
param_name: 'event_name',
},
{
example: 'Dubai',
param_name: 'location',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '767076159336759',
name: 'purchase_receipt',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
example: {
header_handle: [
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
],
},
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
example: {
body_text: [['credit', 'CS Mutual', 'receipt']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1469258364071127',
name: 'discount_coupon',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
example: {
body_text_named_params: [
{
example: '30',
param_name: 'discount_percentage',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
example: ['SAVE1OFF'],
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1075221534579807',
name: 'support_callback',
status: 'APPROVED',
category: 'UTILITY',
language: 'en',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
{
example: '232323',
param_name: 'ticket_id',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+2112121212',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1023596726651144',
name: 'training_video',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'VIDEO',
example: {
header_handle: [
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
],
},
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'john',
param_name: 'name',
},
{
example: 'July 31',
param_name: 'date',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1106685194739985',
name: 'order_confirmation',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
example: {
body_text: [['blue canvas shoes']],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'POSITIONAL',
},
{
id: '1242180011253003',
name: 'product_launch',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
},
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'blue',
param_name: 'color',
},
],
},
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1449876326175680',
name: 'technician_visit',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
example: {
body_text: [
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
parameter_format: 'POSITIONAL',
},
{
id: '997298832221901',
name: 'greet',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'John',
param_name: 'customer_name',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '632315222954611',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameter_format: 'POSITIONAL',
},
{
id: '787864066907971',
name: 'feedback_request',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1938057163677205',
name: 'address_update',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
example: {
body_text: [['John', '123 Main St', 'support@telco.com']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1644094842949394',
name: 'delivery_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
type: 'BODY',
example: {
body_text: [['John', 'Jan 1, 2024']],
},
},
],
parameter_format: 'POSITIONAL',
},
];
// Helper function to get variable values from examples
export const getWhatsAppVariables = template => {
const variables = {};
template.components?.forEach(component => {
if (component.example?.body_text_named_params) {
component.example.body_text_named_params.forEach(param => {
variables[param.param_name] = param.example;
});
}
if (component.example?.body_text) {
component.example.body_text[0]?.forEach((value, index) => {
variables[(index + 1).toString()] = value;
});
}
});
return variables;
};
@@ -1,63 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const BANNER_COLOR_MAP = {
info: 'blue',
warning: 'amber',
error: 'ruby',
};
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const dismissedBannerIds = ref(
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
);
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
const visibleBanners = computed(() => {
const banners = globalConfig.value?.activePlatformBanners || [];
return banners.filter(
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
);
});
const formattedMessage = message =>
new MessageFormatter(message).formattedMessage;
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
const dismissBanner = banner => {
const key = dismissKey(banner);
if (dismissedBannerIds.value.includes(key)) return;
dismissedBannerIds.value.push(key);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
dismissedBannerIds.value
);
};
</script>
<template>
<Banner
v-for="banner in visibleBanners"
:key="banner.id"
:color="bannerColor(banner.banner_type)"
:action-label="t('GENERAL_SETTINGS.DISMISS')"
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
@action="dismissBanner(banner)"
>
<span
v-dompurify-html="formattedMessage(banner.banner_message)"
class="text-xs"
/>
</Banner>
</template>
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -1,9 +1,8 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -22,40 +21,16 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
const caller = call?.caller;
return {
conversation,
inbox,
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
caller?.phone ||
'Unknown caller',
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
avatar: sender?.avatar || sender?.thumbnail,
};
};
@@ -74,22 +49,21 @@ const handleEndCall = async () => {
};
const handleJoinCall = async call => {
if (!call || isJoining.value) return;
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
// The conversation may not be hydrated yet (post-refresh seeding path);
// call.inboxId already carries what joinCall needs.
const result = await joinCall({
conversationId: call.conversationId,
inboxId: call.inboxId || conversation?.inbox_id,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result && conversation) {
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
@@ -188,30 +162,6 @@ watch(
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
@@ -0,0 +1,244 @@
<script setup>
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useVoiceCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('voice_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('voice_call:permission_granted', onPermissionGranted);
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{
call.provider === 'twilio'
? 'Incoming voice call'
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
<template v-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -51,7 +51,6 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -67,7 +66,6 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -517,9 +515,7 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -715,17 +711,12 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -753,9 +744,6 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -17,8 +17,6 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -364,33 +362,19 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown(view, event) {
onKeydown() {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -4,7 +4,6 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
@@ -13,14 +12,15 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import {
prepareOutboundOffer,
cleanupInboundWebRTC,
} from 'dashboard/composables/useVoiceCallSession';
const props = defineProps({
chat: {
@@ -38,7 +38,10 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
useInbox();
const voiceCallsStore = useVoiceCallsStore();
const isInitiatingCall = ref(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -100,42 +103,96 @@ const hasMultipleInboxes = computed(
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
// Returns the provider to use for an outbound call, based on the inbox
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
// use :twilio. Returns null if outbound calling isn't supported on this
// channel.
const callProvider = computed(() => {
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
return 'whatsapp';
}
if (voiceCallEnabled.value) {
return 'twilio';
}
return null;
});
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const canInitiateVoiceCall = computed(() => {
if (!callProvider.value) return false;
if (voiceCallsStore.hasVoiceCall) return false;
return true;
});
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
// the browser builds the SDP offer locally before any network call, so Meta
// gets the agent's real fingerprint and the eventual answer applies cleanly
// in the cable handler. Twilio still POSTs first and waits for the
// synchronous agent_offer in the response (its OutboundCallBuilder produces
// the SDP server-side). On 138006 (no call permission) we tear down the local
// PC immediately so the mic light goes off while the contact's phone shows
// the consent prompt.
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
return;
isInitiatingCall.value = true;
const isWhatsApp = callProvider.value === 'whatsapp';
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
let sdpOffer = null;
if (isWhatsApp) {
sdpOffer = await prepareOutboundOffer({});
}
const response = await VoiceCallsAPI.initiate(
currentChat.value.id,
callProvider.value,
isWhatsApp ? { sdpOffer } : {}
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
// Tear down the local PC; we'll rebuild from scratch on retry.
cleanupInboundWebRTC();
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
callsStore.setCallActive(response.call_id);
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
const callData = response.data || {};
voiceCallsStore.setActiveCall({
id: callData.id,
callId: callData.call_id,
provider: callData.provider || callProvider.value,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
cleanupInboundWebRTC();
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
@@ -199,18 +256,19 @@ const startWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="whatsappCallSession.isInitiating.value"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>
<button
v-if="canInitiateVoiceCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -0,0 +1,36 @@
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { useAlert } from 'dashboard/composables';
/**
* Detects a stale active call on page load and cleans it up. Direct
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
* Best we can do is terminate the stranded Call so the agent isn't shown
* "in a call" forever.
*/
export function useCallReconnection() {
const callsStore = useVoiceCallsStore();
const { t } = useI18n();
onMounted(async () => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
try {
const { data } = await VoiceCallsAPI.active();
if (!data?.call && !data?.id) return;
const activeCallData = data.call || data;
try {
await VoiceCallsAPI.terminate(activeCallData.id);
} catch {
// best-effort — Rails-side cleanup
}
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
} catch {
callsStore.clearActiveCall();
}
});
}
@@ -1,25 +1,11 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -29,7 +15,6 @@ export function useCallSession() {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
watch(
hasActiveCall,
@@ -44,75 +29,20 @@ export function useCallSession() {
{ immediate: true }
);
// Warn before a refresh/close drops a live or ringing call. Cable events
// aren't replayed on reconnect, so a confirmed refresh during ringing would
// leave the agent unable to accept; for active calls the WebRTC session
// dies outright (no rejoin path).
const handleBeforeUnload = event => {
if (!hasActiveCall.value && !hasIncomingCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
// on a hard refresh they leave the calls store empty. Seed it from any
// ringing voice_call message in the conversation cache.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
conversations.forEach(conv => {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== 'voice_call') return;
if (msg.call?.status !== 'ringing') return;
handleVoiceCallCreated(msg, currentUserId);
});
});
};
// Terminate only the active call — ringing calls stay alive on Meta so the
// agent can pick them up after reload (seeded back via the watcher above).
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
seedCallsFromHydratedMessages();
});
// Re-seed when conversations stream in after mount; addCall merges by callSid.
watch(
() => store.getters.getAllConversations?.length,
() => seedCallsFromHydratedMessages()
);
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
if (isWhatsappCall(findCall(callSid))) {
await whatsappSession.endActiveCall();
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -124,18 +54,6 @@ export function useCallSession() {
isJoining.value = true;
try {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -156,15 +74,8 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Drop any half-built WebRTC state so the next click starts fresh.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -172,12 +83,7 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
whatsappSession.rejectIncomingCall(call.callId);
} else {
TwilioVoiceClient.endClientCall();
}
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
@@ -0,0 +1,654 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Timer from 'dashboard/helper/Timer';
import { emitter } from 'shared/helpers/mitt';
// ─────────────────────────────────────────────────────────────────────────────
// Module-level WebRTC + recorder state. One active peer per browser tab.
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
// disambiguates so beforeunload doesn't warn during a clean hangup.
// ─────────────────────────────────────────────────────────────────────────────
let inboundPc = null;
let inboundStream = null;
let inboundAudio = null;
let audioContext = null;
let mediaRecorder = null;
let recordingMime = null;
let recordingChunks = [];
let recordingFilename = null;
let intentionallyClosing = false;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
function pickRecorderMime() {
if (typeof MediaRecorder === 'undefined') return null;
return (
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
);
}
function teardownRecorder() {
mediaRecorder = null;
recordingChunks = [];
recordingMime = null;
recordingFilename = null;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
}
function extensionFromMime(mime) {
if (!mime) return 'webm';
if (mime.startsWith('audio/webm')) return 'webm';
if (mime.startsWith('audio/ogg')) return 'ogg';
return 'webm';
}
// Mix the agent's mic and Meta's remote audio into a single stream so the
// resulting Blob is a real conversation rather than two interleaved channels.
// Web Audio handles the mix; we never apply gain (raw levels matter for
// Whisper's speech-detection floor).
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
if (mediaRecorder || !localStream || !remoteStream) return;
const mime = pickRecorderMime();
if (!mime) {
// eslint-disable-next-line no-console
console.warn(
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
);
return;
}
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000,
});
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const localSource = audioContext.createMediaStreamSource(localStream);
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
const mixDest = audioContext.createMediaStreamDestination();
localSource.connect(mixDest);
remoteSource.connect(mixDest);
recordingMime = mime;
recordingChunks = [];
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size) recordingChunks.push(event.data);
};
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
// beacon to capture most of the call if the user closes the tab.
mediaRecorder.start(5000);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recorder setup failed:', err);
teardownRecorder();
}
}
// stop() returns nothing useful; the final dataavailable fires on the next
// task and onstop fires after that. Wait for onstop, then assemble the blob.
async function stopRecorderAndGetBlob() {
if (!mediaRecorder) return null;
const mr = mediaRecorder;
if (mr.state === 'inactive') {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
teardownRecorder();
return { blob, filename: recordingFilename };
}
return new Promise(resolve => {
const cleanup = () => {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const filename = recordingFilename;
teardownRecorder();
resolve({ blob, filename });
};
mr.onstop = cleanup;
try {
mr.stop();
} catch {
cleanup();
}
});
}
function cleanupInboundWebRTC() {
// Stop recorder first so the source nodes still see live tracks during
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
teardownRecorder();
if (inboundStream) {
inboundStream.getTracks().forEach(track => track.stop());
inboundStream = null;
}
if (inboundPc) {
inboundPc.close();
inboundPc = null;
}
if (inboundAudio) {
inboundAudio.srcObject = null;
if (inboundAudio.parentNode) {
inboundAudio.parentNode.removeChild(inboundAudio);
}
inboundAudio = null;
}
}
function waitForIceGatheringComplete(pc) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
// eslint-disable-next-line no-console
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
}
function attachRemoteTrackHandler(pc, callId, providerCallId) {
pc.ontrack = event => {
let remoteStream = event.streams && event.streams[0];
if (!remoteStream) {
if (!event.track) return;
remoteStream = new MediaStream([event.track]);
}
if (!inboundAudio) {
const audio = document.createElement('audio');
audio.autoplay = true;
document.body.appendChild(audio);
inboundAudio = audio;
}
inboundAudio.srcObject = remoteStream;
inboundAudio.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[Voice Call] audio.play() rejected:', err);
});
if (inboundStream) {
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
}
};
}
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
// cable event, opens its mic, builds the answer, and ships it back so Rails
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
async function prepareInboundAnswer({
callId,
providerCallId,
sdpOffer,
iceServers,
}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Outbound: browser builds the offer locally, before any network call. Rails
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
// later via the voice_call.outbound_connected cable event.
async function prepareOutboundOffer({
callId,
providerCallId,
iceServers,
} = {}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
// from the actionCable handler when voice_call.outbound_connected lands.
async function applyOutboundAnswer(sdpAnswer) {
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
if (inboundPc.signalingState !== 'have-local-offer') {
// Already applied — duplicate cable delivery. No-op rather than detonate.
return;
}
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Some browsers fire ontrack synchronously inside setRemoteDescription;
// others delay it. If we already have a remote stream, kick the recorder.
if (inboundAudio?.srcObject && inboundStream) {
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
}
}
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
// Standalone for the message bubble's Accept button. Branches on whether the
// store has an SDP offer (WhatsApp direct mode = always yes).
export async function acceptVoiceCallById(callId) {
const callsStore = useVoiceCallsStore();
if (callsStore.hasActiveCall) {
return { success: false, error: 'active_call_exists' };
}
let call = callsStore.incomingCalls.find(
c => c.id === callId || c.callId === String(callId)
);
let sdpOffer = call?.sdpOffer;
let iceServers = call?.iceServers;
let providerCallId = call?.callId;
if (!sdpOffer) {
const { data } = await VoiceCallsAPI.show(callId);
if (data.status !== 'ringing') {
return { success: false, error: 'not_ringing' };
}
sdpOffer = data.sdp_offer;
iceServers = data.ice_servers;
providerCallId = data.call_id;
call = {
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer,
iceServers,
};
callsStore.addIncomingCall(call);
}
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
// network call so the cable handler doesn't drop a frame on a null guard.
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
return { success: true, call: activeCallData };
} catch (err) {
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall?.();
throw err;
}
}
// Best-effort upload. Errors are swallowed because the call has already
// completed by the time we get here; surfacing failures isn't actionable.
async function uploadRecordingBlob(callId) {
if (!mediaRecorder && recordingChunks.length === 0) return;
try {
const result = await stopRecorderAndGetBlob();
if (!result?.blob || result.blob.size === 0) return;
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recording upload failed:', err);
}
}
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
export function useVoiceCallSession() {
const { t } = useI18n();
const callsStore = useVoiceCallsStore();
const getters = useStoreGetters();
const accountId = computed(() => getters.getCurrentAccountId.value);
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const isOutboundRinging = computed(
() =>
activeCall.value?.direction === 'outbound' &&
activeCall.value?.status === 'ringing'
);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
// the only one that ships audio to the server. uploadRecordingBlob
// gathers the blob via the recorder's onstop listener and then frees the
// recorder — only AFTER that completes do we close the PC and stop tracks.
callsStore.registerCleanupCallback(async callId => {
const idForUpload = callId || activeCall.value?.id;
durationTimer.stop();
callDuration.value = 0;
try {
if (idForUpload) await uploadRecordingBlob(idForUpload);
} finally {
cleanupInboundWebRTC();
}
});
// Warn the user if they try to navigate away mid-call. The actual cleanup
// happens in the pagehide handler — beforeunload is purely the warning.
const handleBeforeUnload = e => {
if (!callsStore.activeCall || intentionallyClosing) return undefined;
e.preventDefault();
e.returnValue = '';
// Some browsers (older Safari/Firefox) only honour a returned string.
// eslint-disable-next-line consistent-return
return '';
};
// Fires after the user confirms close (or on tab switch). We use fetch
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
// authenticates via devise-token-auth headers (`access-token`, `client`,
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
// fetch+keepalive sends both cookies AND custom headers and the request
// survives page unload (capped at ~64KB body, fine for terminate).
const handlePageHide = () => {
const call = callsStore.activeCall;
if (!call || intentionallyClosing) return;
// Stop the recorder synchronously; final dataavailable already fired via
// the 5s timeslice, so chunks[] is approximately current.
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
const sessionHeaders = (() => {
try {
return JSON.parse(
decodeURIComponent(
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
[])[2] || '%7B%7D'
)
);
} catch {
return {};
}
})();
const authHeaders = {
'access-token': sessionHeaders['access-token'] || '',
client: sessionHeaders.client || '',
uid: sessionHeaders.uid || '',
expiry: sessionHeaders.expiry || '',
'token-type': sessionHeaders['token-type'] || 'Bearer',
};
if (recordingChunks.length > 0 && accountId.value) {
try {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const fd = new FormData();
fd.append(
'recording',
blob,
recordingFilename || `call_${call.id}.webm`
);
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
{
method: 'POST',
body: fd,
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
if (accountId.value) {
try {
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
{
method: 'POST',
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
cleanupInboundWebRTC();
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
// Start timer when an outbound call transitions to connected.
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
// Floating-widget Accept button. Same WhatsApp-direct flow as
// acceptVoiceCallById — see comments there.
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
const sdpOffer = call.sdpOffer;
const iceServers = call.iceServers;
if (!sdpOffer) {
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
isAccepting.value = false;
return;
}
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId: call.callId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
durationTimer.start();
emitter.emit('voice_call:agent_webrtc_connected');
} catch (err) {
callError.value =
err?.name === 'NotAllowedError'
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[Voice Call] acceptCall error:', err);
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await VoiceCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
intentionallyClosing = true;
try {
await uploadRecordingBlob(call.id);
} catch {
// Best effort
}
try {
await VoiceCallsAPI.terminate(call.id);
} catch {
// Best effort
} finally {
cleanupInboundWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
intentionallyClosing = false;
}
};
const toggleMute = () => {
if (!inboundStream) return;
const audioTrack = inboundStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
const startDurationTimer = () => {
durationTimer.start();
};
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
};
}
@@ -1,352 +0,0 @@
import { ref } from 'vue';
import Cookies from 'js-cookie';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Module-level state lets the cable handlers and unload listeners reach the
// live PeerConnection without prop-drilling refs through every composable.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
// that races cleanup still has data to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
// at offer time). Without STUN the browser only sends host candidates and
// browser→Meta media silently drops through any non-trivial NAT.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
};
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// createMediaStreamSource on a stream with no audio tracks wires up to
// nothing — the recorded mix would be silence. Wait until ontrack fires.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies; without
// resume() the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Reuse the same MediaStream object — the recorder's audioContext source
// taps it once, so reassigning would orphan the recorder.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
// Best-effort — the controller's idempotency guard handles a retry.
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* noop */
}
};
// devise-token-auth requires access-token / client / uid headers on every
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
// the auth payload from the cw_d_session_info cookie that the dashboard sets
// at login. Used by the page-close terminate path below.
const getDeviseAuthHeaders = () => {
try {
const raw = Cookies.get('cw_d_session_info');
if (!raw) return null;
const session = JSON.parse(raw);
return {
'access-token': session['access-token'] || '',
client: session.client || '',
uid: session.uid || '',
expiry: session.expiry || '',
'token-type': session['token-type'] || 'Bearer',
};
} catch (_) {
return null;
}
};
const beaconTerminate = callId => {
if (!callId) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const headers = getDeviseAuthHeaders();
if (!headers) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
// headers — without them devise-token-auth 401s and the call stays open on
// Meta until its carrier-side timeout (~60s).
try {
fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...headers },
body: '{}',
}).catch(() => {});
} catch (_) {
/* noop */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (the cable broadcast can race the
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// The permission-request branch returns no call id; let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
throw e;
} finally {
isInitiating.value = false;
}
};
const endActiveCall = async () => {
if (!activeCallId) {
cleanup();
return;
}
intentionallyClosing = true;
const callIdSnapshot = activeCallId;
try {
await stopRecorderAndUpload(callIdSnapshot);
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance, so the shared session
// surface is exposed as module-level functions for them.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
};
export const cleanupWhatsappSession = () => cleanup();
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
beaconTerminate(activeCallId);
};
@@ -1,6 +1,5 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
+53 -31
View File
@@ -4,11 +4,8 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
handleWhatsappRemoteEnd,
} from 'dashboard/composables/useWhatsappCallSession';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
const { isImpersonating } = useImpersonation();
@@ -38,11 +35,12 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.ended': this.onVoiceCallEnded,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
};
}
@@ -203,10 +201,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
@@ -216,37 +210,65 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
conversationId: data.conversation_id,
caller: data.caller,
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
// the bubble's Accept button can build the WebRTC answer locally
// without re-fetching from /show.
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
caller: data.caller,
});
};
onVoiceCallAccepted = data => {
const voiceCallsStore = useVoiceCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
if (data.accepted_by_agent_id !== currentUserId) {
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.handleCallEnded(data.call_id);
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
// contact picks up. Apply it to the existing local RTCPeerConnection so
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
// by prepareOutboundOffer when the agent clicked the call button.
const voiceCallsStore = useVoiceCallsStore();
const activeCall = voiceCallsStore.activeCall;
if (!activeCall || activeCall.callId !== data.call_id) return;
if (!data.sdp_answer) return;
applyOutboundAnswer(data.sdp_answer)
.then(() => {
voiceCallsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== 'whatsapp') return;
// Await upload before removeCall — the store's sync teardown would otherwise
// wipe the recorder chunks before they reach the server.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop */
}
useCallsStore().removeCall(data.call_id);
onVoiceCallPermissionGranted = data => {
emitter.emit('voice_call:permission_granted', {
contactName: data.contact_name,
});
};
}
@@ -2,7 +2,6 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
@@ -274,18 +273,6 @@ export const scrollCursorIntoView = view => {
}
};
/**
* Collapse the current selection to a cursor near its head. Used to override
* the default Escape -> selectParentNode behavior which would otherwise keep
* the text highlight visible.
*
* @param {EditorView} view - The ProseMirror EditorView
*/
export const collapseSelection = view => {
const { tr, selection } = view.state;
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
+3 -7
View File
@@ -16,21 +16,17 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
@@ -16,7 +16,6 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -455,37 +454,6 @@ describe('stripInlineBase64Images', () => {
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
+9 -75
View File
@@ -22,39 +22,13 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -62,32 +36,14 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const { callSid, callDirection, conversationId, senderId } =
extractCallData(message);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
callDirection,
senderId,
@@ -97,14 +53,8 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const callsStore = useCallsStore();
@@ -116,19 +66,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callSid,
});
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (status === 'ringing') {
if (isNewCall) {
callsStore.addCall({
callSid,
conversationId,
@@ -137,11 +79,3 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -84,8 +84,10 @@
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
"ANSWERED_BY": "Answered by {name}",
"ACCEPT_CALL": "Accept",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
"TRANSCRIPT": "Transcript"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -99,10 +101,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -303,9 +301,7 @@
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic"
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -637,8 +637,7 @@
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT",
"VOICE": "Voice",
"CALLS": "Calls"
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -646,21 +645,7 @@
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
@@ -815,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -36,6 +36,7 @@ import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappCall from './whatsappCall.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
@@ -81,6 +82,7 @@ export default {
...sla,
...snooze,
...teamsSettings,
...whatsappCall,
...whatsappTemplates,
...contentTemplates,
...mfa,
@@ -0,0 +1,20 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"RINGING": "Ringing…",
"CALL_FAILED": "Call failed. Please try again.",
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
}
}
@@ -49,6 +49,7 @@ const navigateTo = computed(() => {
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -20,11 +20,17 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
export default {
components: {
@@ -36,6 +42,7 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -44,6 +51,13 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const voiceCallsStore = useVoiceCallsStore();
// Detect a stale active WhatsApp call left over from a previous tab /
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
// path because Meta caches our DTLS fingerprint; the only safe action
// is to clean up server-side state and tell the agent the call dropped.
useCallReconnection();
return {
uiSettings,
@@ -51,8 +65,15 @@ export default {
accountId,
upgradePageRef,
windowWidth,
// Twilio voice still flows through useCallSession + the calls store.
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
// voiceCalls store. Both widgets can mount simultaneously; only one
// ever has state at a time because they listen to disjoint cable
// events.
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
};
},
data() {
@@ -163,6 +184,7 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -1,6 +1,5 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import store from '../../../store';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
@@ -11,37 +10,6 @@ const CONVERSATION_PERMISSIONS = [
'conversation_participating_manage',
];
const isFolderAvailable = async folderId => {
let folders = store.getters['customViews/getConversationCustomViews'];
if (!folders.length) {
await store.dispatch('customViews/get', 'conversation');
folders = store.getters['customViews/getConversationCustomViews'];
}
return folders.some(folder => folder.id === Number(folderId));
};
const redirectFolderListIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({ name: 'home', params: { accountId: to.params.accountId } });
};
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({
name: 'inbox_conversation',
params: {
accountId: to.params.accountId,
conversation_id: to.params.conversation_id,
},
});
};
export default {
routes: [
{
@@ -145,7 +113,6 @@ export default {
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
beforeEnter: redirectFolderListIfUnavailable,
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -158,7 +125,6 @@ export default {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
beforeEnter: redirectFolderConversationIfUnavailable,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
@@ -22,7 +22,6 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -49,7 +48,6 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -251,16 +249,6 @@ export default {
];
}
if (this.isAWhatsAppCloudChannel && this.isEmbeddedSignupWhatsApp) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'calls-configuration',
name: this.$t('INBOX_MGMT.TABS.CALLS'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -1274,12 +1262,6 @@ export default {
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'calls-configuration'"
class="mx-6 max-w-4xl"
>
<WhatsappCallingPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -11,7 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/template-preview/CSATTemplate.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -1,91 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
isUpdating: false,
};
},
computed: {
phoneNumber() {
return (
this.inbox.provider_config?.phone_number || this.inbox.phone_number
);
},
},
watch: {
'inbox.provider_config.calling_enabled'(val) {
this.callingEnabled = val || false;
},
},
methods: {
async updateCallingSettings() {
this.isUpdating = true;
try {
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
calling_enabled: this.callingEnabled,
},
},
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
/>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
>
<woot-code :script="phoneNumber" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
/>
<div>
<NextButton
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateCallingSettings"
/>
</div>
</div>
</template>
@@ -132,10 +132,8 @@ const headers = computed(() => [
</template>
</BaseTable>
<div
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
>
<p class="text-body-main text-n-slate-11 mb-0">
<div class="flex items-center justify-between mt-4">
<p class="text-body-main text-n-slate-11">
{{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script>
<template>
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script>
<template>
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -1,58 +0,0 @@
export const PLATFORMS = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
};
export const WA_COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
FOOTER: 'FOOTER',
BUTTONS: 'BUTTONS',
};
export const WA_HEADER_FORMATS = {
TEXT: 'TEXT',
IMAGE: 'IMAGE',
VIDEO: 'VIDEO',
DOCUMENT: 'DOCUMENT',
};
export const WA_MEDIA_FORMATS = [
WA_HEADER_FORMATS.IMAGE,
WA_HEADER_FORMATS.VIDEO,
WA_HEADER_FORMATS.DOCUMENT,
];
export const WA_BUTTON_TYPES = {
COPY_CODE: 'COPY_CODE',
};
export const WA_PARAM_FORMATS = {
POSITIONAL: 'POSITIONAL',
NAMED: 'NAMED',
};
export const TWILIO_TYPE_PREFIX = 'twilio/';
export const TWILIO_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
CATALOG: 'catalog',
};
export const TEMPLATE_TYPES = {
WHATSAPP_TEXT: 'whatsapp-text',
WHATSAPP_TEXT_HEADER: 'whatsapp-text-header',
WHATSAPP_MEDIA_IMAGE: 'whatsapp-media-image',
WHATSAPP_MEDIA_VIDEO: 'whatsapp-media-video',
WHATSAPP_MEDIA_DOCUMENT: 'whatsapp-media-document',
WHATSAPP_INTERACTIVE: 'whatsapp-interactive',
WHATSAPP_COPY_CODE: 'whatsapp-copy-code',
TWILIO_TEXT: 'twilio-text',
TWILIO_MEDIA: 'twilio-media',
TWILIO_QUICK_REPLY: 'twilio-quick-reply',
TWILIO_CALL_TO_ACTION: 'twilio-call-to-action',
TWILIO_CARD: 'twilio-card',
};
@@ -1,114 +0,0 @@
import { TemplateTypeDetector } from './TemplateTypeDetector';
import {
PLATFORMS,
TWILIO_TYPE_PREFIX,
WA_COMPONENT_TYPES,
WA_PARAM_FORMATS,
} from './TemplateConstants';
/**
* TemplateNormalizer - Convert platform-specific formats to unified structure
*/
export class TemplateNormalizer {
static normalizeWhatsApp(template) {
const components = template.components || [];
return {
id: template.id,
name: template.name,
platform: PLATFORMS.WHATSAPP,
type: TemplateTypeDetector.detectWhatsAppType(template),
parameterFormat: template.parameter_format || WA_PARAM_FORMATS.POSITIONAL,
header: components.find(c => c.type === WA_COMPONENT_TYPES.HEADER),
body: components.find(c => c.type === WA_COMPONENT_TYPES.BODY),
footer: components.find(c => c.type === WA_COMPONENT_TYPES.FOOTER),
buttons:
components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS)?.buttons ||
[],
variables: this.extractWhatsAppVariables(template),
category: template.category,
language: template.language,
originalTemplate: template,
};
}
static normalizeTwilio(template) {
const typeKey =
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
Object.keys(template.types || {})
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
.find(key => key);
const hyphenatedKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/_/g, '-')}`;
const underscoreKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/-/g, '_')}`;
const typeData =
template.types?.[hyphenatedKey] || template.types?.[underscoreKey] || {};
return {
contentSid: template.content_sid,
name: template.friendly_name,
platform: PLATFORMS.TWILIO,
type: TemplateTypeDetector.detectTwilioType(template),
body: template.body || typeData.body,
media: typeData.media || [],
mediaType: template.media_type || null,
actions: typeData.actions || [],
variables: template.variables || {},
category: template.category || 'utility',
language: template.language || 'en',
originalTemplate: template,
};
}
static extractWhatsAppVariables(template) {
const variables = {};
const components = template.components || [];
components.forEach(component => {
if (component.text) {
const matches = component.text.match(/\{\{([^}]+)\}\}/g) || [];
matches.forEach(match => {
const variable = match.replace(/[{}]/g, '');
if (template.parameter_format === WA_PARAM_FORMATS.NAMED) {
const example =
component.example?.body_text_named_params?.find(
p => p.param_name === variable
)?.example || '';
variables[variable] = example;
} else {
const position = parseInt(variable, 10) - 1;
const example = component.example?.body_text?.[0]?.[position] || '';
variables[variable] = example;
}
});
}
if (component.buttons) {
component.buttons.forEach(button => {
if (button.url) {
const matches = button.url.match(/\{\{([^}]+)\}\}/g) || [];
matches.forEach(match => {
const variable = match.replace(/[{}]/g, '');
const example = button.example?.[0] || '';
variables[variable] = example;
});
}
});
}
});
return variables;
}
static normalize(template, platform) {
switch (platform) {
case PLATFORMS.WHATSAPP:
return this.normalizeWhatsApp(template);
case PLATFORMS.TWILIO:
return this.normalizeTwilio(template);
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
}
@@ -1,143 +0,0 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { TemplateNormalizer } from './TemplateNormalizer';
describe('TemplateNormalizer', () => {
it('normalizes Twilio call-to-action templates with hyphenated keys', () => {
const template = {
types: {
'twilio/call-to-action': {
body: 'CTA body for {{date}}',
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
},
},
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX123',
friendly_name: 'cta_example',
template_type: 'call-to-action',
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-call-to-action');
expect(normalized.body).toBe('CTA body for {{date}}');
expect(normalized.actions).toHaveLength(1);
expect(normalized.variables).toEqual({ date: '01-Jan-2026' });
});
it('normalizes Twilio quick replies', () => {
const template = {
template_type: 'quick_reply',
types: {
'twilio/quick-reply': {
body: 'Pick an option',
actions: [{ id: 'a', title: 'Option A' }],
},
},
variables: {},
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-quick-reply');
expect(normalized.body).toBe('Pick an option');
expect(normalized.actions).toEqual([{ id: 'a', title: 'Option A' }]);
});
it('normalizes Twilio media templates', () => {
const template = {
template_type: 'media',
body: 'Media body {{1}}',
types: {
'twilio/media': {
body: 'Media body {{1}}',
media: ['https://example.com/image.jpg'],
},
},
variables: { 1: 'value' },
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-media');
expect(normalized.media).toEqual(['https://example.com/image.jpg']);
expect(normalized.body).toBe('Media body {{1}}');
});
it('extracts WhatsApp named variables', () => {
const template = {
parameter_format: 'NAMED',
components: [
{
type: 'BODY',
text: 'Hi {{name}}',
example: {
body_text_named_params: [{ param_name: 'name', example: 'John' }],
},
},
],
};
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
expect(variables).toMatchObject({
name: 'John',
});
});
it('extracts WhatsApp positional variables', () => {
const template = {
parameter_format: 'POSITIONAL',
components: [
{
type: 'BODY',
text: 'Hi {{1}}',
example: {
body_text: [['positional']],
},
},
],
};
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
expect(variables).toMatchObject({
1: 'positional',
});
});
it('normalizes WhatsApp media image templates', () => {
const template = {
id: '1',
name: 'order_confirmation',
parameter_format: 'POSITIONAL',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: { header_handle: ['https://example.com/image.jpg'] },
},
{ type: 'BODY', text: 'Hi {{1}}', example: { body_text: [['John']] } },
],
language: 'en',
};
const normalized = TemplateNormalizer.normalizeWhatsApp(template);
expect(normalized.type).toBe('whatsapp-media-image');
expect(normalized.header.format).toBe('IMAGE');
expect(normalized.body.text).toBe('Hi {{1}}');
expect(normalized.variables).toMatchObject({ 1: 'John' });
});
});
@@ -1,63 +0,0 @@
import {
TEMPLATE_TYPES,
TWILIO_TYPE_PREFIX,
TWILIO_TYPES,
WA_BUTTON_TYPES,
WA_COMPONENT_TYPES,
WA_HEADER_FORMATS,
} from './TemplateConstants';
/**
* TemplateTypeDetector - Unified service to identify template types across platforms
*/
export class TemplateTypeDetector {
static detectWhatsAppType(template) {
const components = template.components || [];
const header = components.find(c => c.type === WA_COMPONENT_TYPES.HEADER);
const buttons = components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS);
if (header?.format === WA_HEADER_FORMATS.IMAGE)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE;
if (header?.format === WA_HEADER_FORMATS.VIDEO)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO;
if (header?.format === WA_HEADER_FORMATS.DOCUMENT)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT;
if (buttons) {
const hasCopyCode = buttons.buttons?.some(
b => b.type === WA_BUTTON_TYPES.COPY_CODE
);
if (hasCopyCode) return TEMPLATE_TYPES.WHATSAPP_COPY_CODE;
return TEMPLATE_TYPES.WHATSAPP_INTERACTIVE;
}
if (header?.format === WA_HEADER_FORMATS.TEXT)
return TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER;
return TEMPLATE_TYPES.WHATSAPP_TEXT;
}
static detectTwilioType(template) {
const typeFromTemplate =
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
Object.keys(template.types || {})
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
.find(typeKey => typeKey);
const templateType = (typeFromTemplate || '')
.replace(/-/g, '_')
.replace(/__/g, '_');
switch (templateType) {
case TWILIO_TYPES.MEDIA:
return TEMPLATE_TYPES.TWILIO_MEDIA;
case TWILIO_TYPES.QUICK_REPLY:
return TEMPLATE_TYPES.TWILIO_QUICK_REPLY;
case TWILIO_TYPES.CALL_TO_ACTION:
return TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION;
case TWILIO_TYPES.CATALOG:
return TEMPLATE_TYPES.TWILIO_CARD;
default:
return TEMPLATE_TYPES.TWILIO_TEXT;
}
}
}
@@ -1,106 +0,0 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { TemplateTypeDetector } from './TemplateTypeDetector';
describe('TemplateTypeDetector', () => {
it('detects Twilio call-to-action from hyphenated template_type', () => {
const template = {
template_type: 'call-to-action',
types: {
'twilio/call-to-action': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-call-to-action'
);
});
it('detects Twilio call-to-action when only types key is present', () => {
const template = {
types: {
'twilio/call_to_action': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-call-to-action'
);
});
it('detects Twilio quick reply', () => {
const template = {
template_type: 'quick_reply',
types: {
'twilio/quick-reply': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-quick-reply'
);
});
it('detects WhatsApp text-with-header and media', () => {
const base = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
{ type: 'BODY', text: 'Body' },
],
};
expect(TemplateTypeDetector.detectWhatsAppType(base)).toBe(
'whatsapp-text-header'
);
const withImage = {
...base,
components: [{ type: 'HEADER', format: 'IMAGE' }, base.components[1]],
};
expect(TemplateTypeDetector.detectWhatsAppType(withImage)).toBe(
'whatsapp-media-image'
);
});
it('detects WhatsApp copy code vs call-to-action buttons', () => {
const copyCode = {
components: [
{ type: 'BODY', text: 'Hi' },
{
type: 'BUTTONS',
buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
},
],
};
const callToAction = {
components: [
{ type: 'BODY', text: 'Hi' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', text: 'Visit website' }],
},
],
};
expect(TemplateTypeDetector.detectWhatsAppType(copyCode)).toBe(
'whatsapp-copy-code'
);
expect(TemplateTypeDetector.detectWhatsAppType(callToAction)).toBe(
'whatsapp-interactive'
);
});
it('detects Twilio media', () => {
const template = {
template_type: 'media',
types: { 'twilio/media': {} },
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-media'
);
});
});
@@ -16,7 +16,6 @@ import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -394,18 +393,19 @@ const actions = {
}
},
updateConversation({ commit, dispatch, rootGetters }, conversation) {
const sender = conversation.meta?.sender;
updateConversation({ commit, dispatch }, conversation) {
const {
meta: { sender },
} = conversation;
commit(types.UPDATE_CONVERSATION, conversation);
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
if (sender) dispatch('contacts/setContact', sender);
dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/**
* Gets a value from a conversation based on the attribute key
@@ -121,8 +121,7 @@ const resolveValue = candidate => {
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
@@ -132,9 +131,8 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, match if any filter value is present.
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
return filterValue.some(val => conversationValue.includes(val));
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
@@ -416,40 +416,6 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
// a conversation matches if ANY of the filter labels is on it.
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
const conversation = { labels: ['support'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
const conversation = { labels: ['new'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [
@@ -368,7 +368,7 @@ export const templates = [
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
@@ -400,7 +400,7 @@ export const templates = [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+23232323',
phone_number: '+16506677566',
},
],
},
+6 -43
View File
@@ -1,16 +1,7 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
const teardownByProvider = call => {
if (call?.provider === 'whatsapp') {
cleanupWhatsappSession();
} else {
TwilioVoiceClient.endClientCall();
}
};
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
@@ -25,28 +16,15 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
if (!TERMINAL_STATUSES.includes(status)) return;
const call = this.calls.find(c => c.callSid === callSid);
// WhatsApp recordings live in the in-memory recorder until voice_call.ended
// uploads them; tearing down here would race-wipe those chunks.
if (call?.provider === 'whatsapp') {
this.calls = this.calls.filter(c => c.callSid !== callSid);
return;
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
}
this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) return;
const existing = this.calls.find(c => c.callSid === callData.callSid);
if (existing) {
// Merge so a later cable event with sdp_offer/provider/caller fills in
// gaps left by the earlier message.created path (and vice versa).
Object.assign(existing, callData, { isActive: existing.isActive });
return;
}
const exists = this.calls.some(call => call.callSid === callData.callSid);
if (exists) return;
this.calls.push({
...callData,
@@ -57,7 +35,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
teardownByProvider(callToRemove);
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -70,27 +48,12 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
const active = this.calls.find(c => c.isActive);
teardownByProvider(active);
TwilioVoiceClient.endClientCall();
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
},
});
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
export const useVoiceCallsStore = defineStore('voiceCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action (per account)
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasVoiceCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
// Snapshot the DB id before nulling activeCall so the composable can
// POST upload_recording with the right path even after we clear state.
const dbCallId = this.activeCall?.id;
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
this.activeCall = null;
}
},
},
});
@@ -94,29 +94,6 @@ export const shortTimestamp = (time, withAgo = false) => {
return convertToShortTime;
};
/**
* Formats a duration in seconds into mm:ss or hh:mm:ss.
* @param {number|string} durationInSeconds - Duration in seconds.
* @returns {string} Formatted duration string. Empty string for invalid input.
*/
export const formatDuration = durationInSeconds => {
if (durationInSeconds === null || durationInSeconds === undefined) return '';
const totalSeconds = Number(durationInSeconds);
if (Number.isNaN(totalSeconds) || totalSeconds < 0) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const mm = minutes.toString().padStart(2, '0');
const ss = seconds.toString().padStart(2, '0');
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`;
}
return `${mm}:${ss}`;
};
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.
@@ -24,7 +24,6 @@ const {
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv,
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
} = window.globalConfig || {};
const state = {
@@ -50,7 +49,6 @@ const state = {
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
activePlatformBanners: activePlatformBanners || [],
};
export const getters = {
+6 -17
View File
@@ -23,24 +23,13 @@ class HookJob < MutexApplicationJob
private
def process_slack_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
message = event_data[:message]
case event_name
when 'message.created'
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
when 'message.updated'
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
# Skip other content types to avoid unnecessary job enqueues on every message update.
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
# while submitted_values is already present on the message.
return unless event_data[:previous_changes]&.key?('content_attributes')
::UpdateSlackMessageJob.perform_later(message, hook)
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
end
@@ -1,7 +0,0 @@
class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
queue_as :scheduled_jobs
def perform; end
end
Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
-11
View File
@@ -1,11 +0,0 @@
class UpdateSlackMessageJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
end
end
end
+1 -29
View File
@@ -1,9 +1,5 @@
class Webhooks::WhatsappEventsJob < MutexApplicationJob
class Webhooks::WhatsappEventsJob < ApplicationJob
queue_as :low
# Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise
# a webhook that arrives just after the lock is acquired can exhaust retries before the
# holder finishes and silently drop its message.
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
@@ -13,20 +9,6 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
return
end
sender_id = contact_sender_id(params)
return process_events(channel, params) if sender_id.blank?
# Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact)
# so the first webhook creates the conversation and the rest append to it.
# 30s TTL covers the attachment download + transaction — the default 1s expires
# mid-processing and lets a concurrent webhook re-acquire before the first commit.
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id)
with_lock(key, 30.seconds) do
process_events(channel, params)
end
end
def process_events(channel, params)
if message_echo_event?(params)
handle_message_echo(channel, params)
else
@@ -87,16 +69,6 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
private
# Echo payloads reverse the fields — `from` is the business number and `to` is the contact.
# Returns nil for status-only webhooks so they bypass the lock.
def contact_sender_id(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
message = (value[:messages] || value[:message_echoes])&.first
return if message.blank?
message[:to] || message[:from]
end
def channel_is_inactive?(channel)
return true if channel.blank?
return true if channel.reauthorization_required?
+2 -2
View File
@@ -43,7 +43,7 @@ class HookListener < BaseListener
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
HookJob.perform_later(hook, event.name, message: message)
end
end
@@ -59,7 +59,7 @@ class HookListener < BaseListener
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created', 'message.updated'],
'slack' => ['message.created'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
-12
View File
@@ -104,23 +104,11 @@ class Attachment < ApplicationRecord
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
# which makes <audio> elements download instead of play. Force inline so the
# call-recording chip (and any other audio bubble) can stream directly.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def inline_audio_url
return '' unless file.attached?
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
end
def file_metadata
metadata = {
extension: extension,
+2 -2
View File
@@ -61,6 +61,8 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -69,8 +71,6 @@ class Channel::TwilioSms < ApplicationRecord
end
end
private
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }

Some files were not shown because too many files have changed in this diff Show More