## Linear Ticket - https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling ## Description Keeps WhatsApp voice calls in the same thread as the chat when a caller has adopted a **WhatsApp username** and hidden their phone number. This makes the inbound-call path BSUID-aware, reusing the same identifier the messaging pipeline keys on so calls land on the existing `ContactInbox`/conversation. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Locally via UI ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
92 lines
3.3 KiB
Ruby
92 lines
3.3 KiB
Ruby
class Voice::InboundCallBuilder
|
|
attr_reader :inbox, :call_sid, :provider, :extra_meta, :source_ids, :contact_attributes
|
|
|
|
# `caller` carries the contact identity: { source_ids:, contact_attributes: }. Twilio passes
|
|
# its single +phone source_id; WhatsApp passes the message-path phone/user_id/parent_user_id set.
|
|
def self.perform!(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {})
|
|
new(inbox: inbox, call_sid: call_sid, caller: caller, provider: provider, extra_meta: extra_meta).perform!
|
|
end
|
|
|
|
def initialize(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {})
|
|
@inbox = inbox
|
|
@call_sid = call_sid
|
|
@provider = provider.to_sym
|
|
@extra_meta = extra_meta || {}
|
|
@source_ids = Array(caller[:source_ids]).compact_blank
|
|
@contact_attributes = caller[:contact_attributes] || {}
|
|
end
|
|
|
|
def perform!
|
|
existing = find_existing_call
|
|
return existing if existing
|
|
|
|
ActiveRecord::Base.transaction do
|
|
contact_inbox = ensure_contact_inbox!
|
|
contact = contact_inbox.contact
|
|
conversation = resolve_conversation!(contact, contact_inbox)
|
|
call = create_call!(contact, conversation)
|
|
message = Voice::CallMessageBuilder.new(call).perform!
|
|
call.update!(message_id: message.id)
|
|
call
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique
|
|
# A concurrent provider retry won the create race; return what now exists.
|
|
find_existing_call || raise
|
|
end
|
|
|
|
private
|
|
|
|
def account
|
|
inbox.account
|
|
end
|
|
|
|
def find_existing_call
|
|
Call.where(account_id: account.id, inbox_id: inbox.id)
|
|
.find_by(provider: provider, provider_call_id: call_sid)
|
|
end
|
|
|
|
# Resolve the contact/ContactInbox the same way inbound messages do — match across every
|
|
# candidate source_id (phone + BSUID aliases) so a call reuses the existing thread, creating
|
|
# one keyed on the first (phone, else BSUID) only when none exists. Shared with messaging via
|
|
# ContactInboxSourceIdResolver, which also rescues the concurrent-webhook create race.
|
|
def ensure_contact_inbox!
|
|
ContactInboxSourceIdResolver.new(
|
|
inbox: inbox, source_ids: source_ids, contact_attributes: contact_attributes
|
|
).perform
|
|
end
|
|
|
|
# Mirror Whatsapp::IncomingMessageBaseService#set_conversation: reuse this row's open conversation (or last when locked), else create.
|
|
def resolve_conversation!(contact, contact_inbox)
|
|
reusable = if inbox.lock_to_single_conversation
|
|
contact_inbox.conversations.last
|
|
else
|
|
contact_inbox.conversations.where.not(status: :resolved).last
|
|
end
|
|
return reusable if reusable
|
|
|
|
account.conversations.create!(
|
|
contact_inbox_id: contact_inbox.id,
|
|
inbox_id: inbox.id,
|
|
contact_id: contact.id,
|
|
status: :open
|
|
)
|
|
end
|
|
|
|
def create_call!(contact, conversation)
|
|
call = Call.create!(
|
|
account: account,
|
|
inbox: inbox,
|
|
conversation: conversation,
|
|
contact: contact,
|
|
provider: provider,
|
|
direction: :incoming,
|
|
status: 'ringing',
|
|
provider_call_id: call_sid,
|
|
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
|
|
)
|
|
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
|
|
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
|
|
call
|
|
end
|
|
end
|