Meta's Calls API documents `action: 'connect'` (alongside `pre_accept`,
`accept`, `reject`, `terminate`) as the required field on POST /calls
for business-initiated calls. The previous body used `type: 'audio'`,
which is the /messages media shape — Meta currently accepts it because
unknown fields are ignored, but that's an implicit contract that could
break the moment validation tightens.
Messenger ingest saves the Attachment row before attaching the blob, so
the after_create_commit broadcast bails on file.attached? and the FE
never gets the audio bubble until a refresh (or until Whisper finishes,
or never if transcription is disabled or returns blank). Re-fire the
message update after attach completes.
Reject conversation_id hints that point to a different contact in the
same voice inbox, so reuse only happens when the agent is genuinely
inside the open thread for the dialed contact.
Messenger ingest saves the Attachment row before attaching the blob, so
the after_create_commit broadcast bails on file.attached? and the FE
never gets the audio bubble until a refresh (or until Whisper finishes,
or never if transcription is disabled or returns blank). Re-fire the
message update after attach completes.
Reject conversation_id hints that point to a different contact in the
same voice inbox, so reuse only happens when the agent is genuinely
inside the open thread for the dialed contact.
- Contact-panel call button now continues in the open conversation when
the conversation's inbox is voice-capable, mirroring the header button.
For non-voice channels it falls back to the inbox picker.
- Twilio and WhatsApp paths share the same upstream decision; both pass
a conversationId hint to the provider so the agent stays in the same
thread.
- Floating call widget redesigned: avatar + name + phone with duration
on top, a clickable "View chat history" pill linking to the
conversation, and labeled End / Join action buttons. Mute is preserved
for active WhatsApp calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Accept an optional conversation_id in POST /contacts/:id/call. When the
hint matches the picked voice inbox, OutboundCallBuilder reuses that
conversation instead of creating a new one — mirroring the WhatsApp
calling flow so the agent stays in the same thread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conflicts resolved:
- config/locales/en.yml — kept the new
conversations.messages.voice_call.{twilio,whatsapp} keys this branch added.
- enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
— kept this branch's calls_phone_id_path helper that pins to
WHATSAPP_API_VERSION (v22) for the Calls API endpoints. Develop's version
from PR #14312 used the OSS phone_id_path (v13) which doesn't support the
Calls API.
Matches the existing voice-configuration tab gating so admins on accounts
without the premium channel_voice entitlement don't see the WhatsApp Calling
configuration tab.
- Add 'calls' to the WhatsApp WABA override_callback subscribed_fields so a
fresh embedded-signup connection (or a re-register) automatically receives
call webhooks from Meta. Previously required a manual App Dashboard step.
- Gate Channel::Whatsapp#voice_enabled? on the account-level 'channel_voice'
feature flag, matching the entitlement Twilio voice channel creation
already requires. Cascades through the controller, webhook services, and
inbox jbuilder voice_enabled serialization (FE button hides automatically).
The BE now returns 422 (instead of 200) when the contact hasn't opted in
yet — see companion fix in WhatsappCallsController#render_permission_request.
Detect that shape in the catch block and surface it as a normal response so
the existing banner branch in ConversationHeader.vue keeps working without
falling into the error toast path.
- Return 422 (not 200) from `WhatsappCallsController#initiate` permission
branch so clients can't mistake the opt-in template path for a
successful dial. Body shape unchanged (`{ status: ... }`).
- Drop the `file.attached?` guard from `enqueue_audio_transcription`.
The social-media ingest path (Messages::Messenger::MessageBuilder)
saves the Attachment before attaching the blob, and the guard was
silently dropping transcription for those audio messages.
AudioTranscriptionJob already has retry_on FileNotFoundError to ride
out the create-then-attach race.
## Description
* Added Meta webhook HMAC validation in meta_token_verify_concern.rb.
* Wired it into instagram_controller.rb and whatsapp_controller.rb.
* WhatsApp now verifies X-Hub-Signature-256 with WHATSAPP_APP_SECRET.
* Instagram now verifies with either FB_APP_SECRET or
INSTAGRAM_APP_SECRET.
* Updated request specs so missing/invalid signatures return 401 and
valid signatures still enqueue jobs.
Fixes # (issue):
[CW-6786](https://linear.app/chatwoot/issue/CW-6786/ghsa-7rw7-pc8v-mrr3-unauthenticated-message-injection-via-missing)
## Type of change
Please delete options that are not relevant.
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
* Updated the controller specs and ran them successfully.
* The original issue is no longer reproducible.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] 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
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] 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>
The legacy-row stub for Channel::Voice belongs with the upstream
DropChannelVoice migration cleanup, not with WhatsApp call wiring.
Removing it keeps the PR's surface focused on the call pipeline.
# Pull Request Template
## Description
Captain (v1) makes false promises by saying it will handoff but doesn't.
This happens due to an exact string match comparison and the prompt
gives the model a lot of responsibilities:
- identity
- what to respond
- obey custom instructions
- decide on tool calls
This PR decouples responsibility, the core prompt responds, and an
additional llm call evaluates if handoff was needed or not after that
message.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
Locally
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
# Pull Request Template
## Description
Fixes
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-h2-when-using-enter-line-before
**1**. Fixes an issue where the signature delimiter `--` gets parsed as
an H2 when using **Enter** (new paragraph) before or after it, causing
it to render as a bold `\` in the message bubble.
* Ensures `--` renders as plain text
* Aligns renderer with parser behavior (both disable `lheading`)
* Prevents stray `\` from appearing as heading text
**2**. Also fixes a related editor issue where toggling signature
**off** leaves behind a stray `\` or `-- \`.
* Strips blank paragraph markers (`\`) and dangling hard breaks
(`\<newline>`) from ProseMirror serializer
* Applied in both `appendSignature` and `removeSignature`
* Replaces `trimEnd()` with shared helpers (`trimTrailingBlanks` /
`stripTrailingBlankMarkers`)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="194" height="204" alt="image"
src="https://github.com/user-attachments/assets/b286ab50-7f89-4910-a552-1568902b93b3"
/>
**After**
<img width="194" height="220" alt="image"
src="https://github.com/user-attachments/assets/658cd543-bce2-46e2-a319-35e5374f1aef"
/>
**Editor**
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-in-h2-when-using-enter-line#comment-5814b882
### Steps
#### Editor
1. Enable agent signature
2. Add and remove new lines around the signature using Enter/shift enter
3. Toggle signature off
4. Notice stray `\` or `-- \` remains
#### Bubble
1. Enable agent signature
2. Send a message using Enter between lines
3. Verify `--` renders correctly (no H2, no bold `\`)
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] 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>
# Pull Request Template
## Description
This PR fixes multiple issues related to regex patterns and validation
for custom attributes.
1. Fixed regex patterns being double-escaped when saving from Add and
Edit flows
2. Fixed regex validation not being enforced in the widget pre-chat form
3. Minor UI improvements in the Add/Edit custom attribute dialog
Fixes
[CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui),
https://github.com/chatwoot/chatwoot/issues/13771
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Loom video**
**Before**
https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50
**After**
https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
A pile of related fixes around the dashboard's WhatsApp call flow:
- ConversationHeader / Contacts/VoiceCallButton: drop the immediate
setCallActive at initiate time. The call sits in incomingCalls
(callDirection: outbound) until the backend signals real pickup, so
the duration timer never starts pre-pickup. Phone button is disabled
whenever there is an active or incoming call.
- FloatingCallWidget:
* Loop a ringtone (bell.mp3) for inbound ringing only.
* Hide the green Join button for outbound — the agent has nothing to
"join", and clicking it routed through acceptIncomingCall →
prepareInboundAnswer → cleanup() and tore down the live outbound
session before the API 409 ("already accepted by another agent").
* Auto-join watcher skips whatsapp outbound (Twilio's joinConference
flow only).
- useCallSession:
* joinCall short-circuits for outbound calls — defense-in-depth so
no future surface can re-trigger the destroyed-session bug.
* endCall + outbound rejectIncomingCall pass call.callId to
endActiveCall, so terminate fires even if module state was wiped.
- useWhatsappCallSession:
* New recorderArmed flag, reset by cleanup. ontrack only calls
setupRecorder when armed.
* Inbound's acceptIncomingCall arms the recorder before the API
round-trip (agent click = pickup).
* armOutboundRecorder exported for the cable handler when ACCEPTED
arrives.
* endActiveCall accepts a callIdOverride to fall back when the
module's activeCallId was nulled by an earlier cleanup.
- actionCable:
* Split the cable contract: outbound_connected only applies the SDP
answer (tunnel-up signal); outbound_accepted (new) is the real
pickup signal — flips active and arms the recorder.
Outbound IN_PROGRESS no longer claims "They answered" — the backend can
flip in_progress before the contact actually picks up (Twilio agent-join
flow), so the label "Call in progress" stands on its own. Outbound
no_answer now reads "Contact didn't pick up" instead of the inbound
"Missed call / No answer" framing, and outbound ringing shows
"Calling…" instead of "Not answered yet".
Surface inbox.provider_config['call_permission_request_body'] in the
WhatsappCallingPage settings as an editable textarea — saving submits the
new key alongside calling_enabled. Blank trims to null so the i18n default
keeps applying. Backend reads the override before the i18n fallback.
Meta delivers two payload shapes under field=calls: value.calls[] (event:
connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The
dispatcher only forwarded the calls[] array, so status webhooks were
silently dropped.
Empirically `connect` for BUSINESS_INITIATED outbound fires when the
WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact
actually answers. The terminate webhook's start_time aligns with the
ACCEPTED status timestamp, confirming ACCEPTED is the true pickup.
- whatsapp_events_job: route value.statuses[] (type=call) to
IncomingCallService under the same per-call_id mutex.
- incoming_call_service: handle_status routes ACCEPTED to
mark_outbound_accepted (flip in_progress, set started_at, broadcast
voice_call.outbound_accepted). Strip the in_progress flip from the
connect handler — connect now only stores the SDP answer + broadcasts
voice_call.outbound_connected so the browser can complete the DTLS
handshake during ringing.
Emit a conversation activity message when the call permission template is
sent (in the controller's render_permission_request) and another when the
contact accepts the prompt (in CallPermissionReplyService). Read an inbox
provider_config['call_permission_request_body'] override before falling
back to the i18n default — surfaced in the inbox UI in a separate change.
Two production-grade fixes to the existing audio transcription service.
**Independent of the WhatsApp Calling work** — these affect every audio
attachment that goes through Whisper (voice notes, call recordings,
voicemails, etc.).
## Closes
- [PLA-151 — PR-5: Recording Upload + Transcription
Pipeline](https://linear.app/chatwoot/issue/PLA-151/pr-5-recording-upload-transcription-pipeline)
## Why this is needed
### 1. Whisper rejects payloads larger than 25 MB
OpenAI's [Whisper
API](https://platform.openai.com/docs/guides/speech-to-text) hard-caps
file uploads at 25 MB. Long audio recordings — voice notes from chatty
contacts, ~70+ min Opus call recordings — currently hit OpenAI with the
full payload and 413 (\`Payload Too Large\`). The job retries via the
existing \`Faraday::BadRequestError\` discard path, but the agent still
sees a transcription failure for an attachment we knew was too big up
front.
This PR adds a pre-flight \`audio_too_large?\` check via the blob's
\`byte_size\` and returns a controlled error without hitting OpenAI. The
audio attachment is preserved (agents can still listen), only the
transcription is skipped.
### 2. Whisper hallucinates on silence at non-zero temperature
At \`temperature: 0.4\` (the previous value), Whisper produces
well-documented hallucinated repeats on silence and near-silent segments
— e.g. \`Oh, dear. Oh, dear. Oh, dear.\` filling the transcript. This
shows up in real recordings whenever there's a hold or quiet moment.
\`temperature: 0.0\` matches OpenAI's recommended default for
transcription and eliminates the spirals.
Reference:
[openai/whisper#928](https://github.com/openai/whisper/discussions/928),
[openai-python#1010](https://github.com/openai/openai-python/issues/1010).
## Are WhatsApp call recordings already handled?
Yes — by the existing pipeline, **before this PR**:
\`\`\`
Browser MediaRecorder → upload_recording (PR-4)
→ @call.message.attachments.create!(file_type: :audio, ...)
→ Enterprise::Concerns::Attachment#enqueue_audio_transcription
(after_create_commit hook)
→ Messages::AudioTranscriptionJob.perform_later(attachment.id)
→ Messages::AudioTranscriptionService → Whisper
\`\`\`
The \`after_create_commit\` hook already fires for every audio
attachment regardless of source. PR-4's \`upload_recording\` endpoint
creates the attachment; the existing job/service take it from there. No
new wiring needed.
This PR just makes the existing service more robust:
- Calls longer than ~70 min (Opus 48 kbps) no longer 413 against OpenAI
- Quiet recordings no longer produce hallucinated transcripts
## How to test
\`\`\`ruby
# In rails console with a real audio attachment:
service = Messages::AudioTranscriptionService.new(Attachment.audio.last)
# Normal-sized audio: unchanged behaviour
service.perform # => { success: true, transcriptions: ... }
# Large audio: new guard returns error instead of 413-ing OpenAI
allow(attachment.file.blob).to
receive(:byte_size).and_return(30.megabytes)
service.perform # => { error: 'Audio too large for Whisper' }
\`\`\`
Existing transcription specs cover the happy path; one new spec
exercises the byte-limit guard.
## Risk
Low. Both changes are pre-flight guards or parameter values — they
reduce the surface of OpenAI calls that can fail. Failure to transcribe
is already non-fatal (the audio attachment is preserved either way).
When an inbound voice call ends, the conversation bubble now (1) renders
an inline audio player as soon as Twilio finishes the recording and (2)
shows the call duration alongside "Call ended" so the agent gets the
at-a-glance summary without opening the recording.
Fixes
https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation
and
https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble
## How to test
1. Set up a Twilio voice inbox and trigger an inbound call.
2. Answer the call from an agent, talk for a few seconds, then hang up.
3. As soon as the call ends, the bubble should read **"Call ended —
0:NN"** (where NN is the call duration in seconds).
4. Wait a few seconds for Twilio to finish processing the recording
(usually <30s after hangup).
5. The same bubble should now show an inline audio player below the
duration. Press play; the recording should be audible.
6. Refresh the page — both the duration and the player should still be
there.
7. End a second call on the same conversation — its bubble should get
its own duration + player, independent of the first.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
- Fix CodeQL alerts by declaring read-only GITHUB_TOKEN scope at the
workflow level. The codespace image publish workflow additionally needs
packages: write to push to ghcr.io.
Wires Meta's WhatsApp Cloud Calling APIs into the voice subsystem so
agents can answer / place calls on WhatsApp Cloud inboxes from the
existing Voice flow. Browser ↔ Meta WebRTC is direct (no media-server
hop); Chatwoot owns the signalling, recording upload, and lifecycle
state on the Call/Message records.
Backend
- API surface: new Api::V1::Accounts::WhatsappCallsController with
show / accept / reject / terminate / initiate / upload_recording.
Enforces conversation-level Pundit visibility, blocks initiate when
the channel isn't embedded-signup voice-enabled, and surfaces the
Meta 138006 (no-call-permission) flow as a throttled opt-in template
send under a conversation lock so concurrent retries can't double-send.
- Whatsapp::CallService — accept/reject/terminate state machine with
call-level locking; wraps Meta API failures (transport or business)
as Voice::CallErrors::CallFailed so the controller renders 422.
- Whatsapp::IncomingCallService — handles Meta connect/terminate
webhooks; pins setup:active on outbound answers, materialises a
missed-call record when terminate arrives before connect, and
broadcasts voice_call.* events to assignee or account streams.
- Whatsapp::Providers::WhatsappCloudService — adds pre_accept/accept/
reject/terminate/initiate/permission-request endpoints. Uses
configurable WHATSAPP_API_VERSION (default v22) since the Calls API
needs v17+ and OSS phone_id_path is locked at v13.
- Audio recordings: Attachment after_create_commit enqueues
transcription and rebroadcasts the message so the FE bubble updates
immediately. Active Storage initializer allows audio MIME types to
serve inline. Audio transcription drops Whisper temperature to 0 to
suppress hallucinations on silence.
- Channel::Voice stub backs legacy inbox rows whose channel_type
survived the DropChannelVoice migration so jbuilder .try chains
short-circuit instead of raising.
Routes / config
- /api/v1/accounts/:id/whatsapp_calls/* (enterprise-only)
- en.yml error strings under errors.whatsapp.calls.*
The FE consumer of this API ships separately on feat/whatsapp-call-ui.
Agents can now click **Join call** directly on the incoming call bubble
in the conversation timeline. If they refresh the page or miss the
floating widget while a call is still ringing, the bubble becomes the
recovery affordance — one click joins the conference, no need to wait
for the next event.
The button only appears when the call is still ringing, no other agent
has claimed it, and the conversation is unassigned or assigned to the
current agent (mirroring the floating widget's eligibility rules). It
disappears as soon as anyone joins the call or it ends.
Fixes
https://linear.app/chatwoot/issue/PLA-117/ability-to-join-the-call-by-clicking-on-call-bubble-in-a-conversation
## How to test
1. Set up a Twilio voice inbox and trigger an inbound call to it.
2. As an agent who is eligible to answer (unassigned conversation, or
assigned to you), open the conversation **without answering from the
floating widget**. The bubble should show a teal **Join call** link
under "Not answered yet".
3. Refresh the page mid-ring — the link should still be there.
4. Click **Join call** — you should be connected to the conference, the
bubble should flip to "Call in progress / You answered", and the link
should disappear.
5. As a second agent who is **not** eligible (conversation assigned to
someone else), open the same conversation — the link should not appear.
6. Wait for the call to end — the bubble should show "Call ended" with
no Join link.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Branch review pass:
- useWhatsappCallSession: drop unused `error` ref and unused exports
(hasActiveWhatsappCall, isWhatsappCallMuted), fold sendWhatsappCallBeacon
into a private beaconTerminate helper since only sendWhatsappTerminateBeacon
consumed it externally. Trim WHAT-comments; keep WHY-comments
(browser-quirk explanations, race-condition notes, auth-cookie rationale).
- VoiceCall.vue bubble: fix `data || data` short-circuit that did nothing —
upstream key transform was the same on both branches; collapse to one read.
- calls/useCallSession/actionCable/FloatingCallWidget/VoiceCallButton:
drop comments that just describe what the next line already says.
Net diff: -63 lines across 7 files. No behavior change.
This PR limits IMAP email fetching to 500 messages per sync run to avoid
expensive/long-running mailbox scans. It also filters out
already-imported emails and Chatwoot-generated notification emails
during the header fetch phase, before fetching full email bodies,
reducing unnecessary IMAP work.
Fixes #CW-7001 (issue) :
https://linear.app/chatwoot/issue/CW-7001/emails-not-syncing
# Pull Request Template
## Description
This PR fixes an issue where agent variables like
`{{agent.name}}`,`{{agent.first_name}}`, `{{agent.last_name}}`, and
`{{agent.email}}` were not rendering in automation messages.
In automation, these either showed blank or returned `Liquid error:
internal`, while the same variables worked fine in macros.
**Cause**
Automation messages are created without a sender, so agent data was
missing during variable rendering. This also caused errors in name
handling, and `email` was not defined at all.
**Solution**
* Handle missing agent data safely to avoid errors
* Add support for `{{agent.email}}`
* Fallback to conversation assignee when sender is not present
Fixes
https://linear.app/chatwoot/issue/CW-6979/template-variables-not-working-in-automated-messages
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Automation**
<img width="759" height="284" alt="image"
src="https://github.com/user-attachments/assets/61a877b7-4984-4a7f-bbef-b8c510dcbdfe"
/>
**Before**
<img width="404" height="105" alt="image"
src="https://github.com/user-attachments/assets/da665ce8-137d-4249-8ee5-a1acc11391db"
/>
**After**
<img width="564" height="132" alt="image"
src="https://github.com/user-attachments/assets/6a80d67c-49c8-4658-b782-ae4acbc77256"
/>
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] 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
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Adds the Meta WhatsApp Cloud API surface needed for browser-based
calling. This is the second slice of the WhatsApp calling feature,
sitting on top of `feat/voice-call-model-wiring` and consumed by later
PRs (incoming-webhook pipeline, call service, frontend).
This PR ships only the provider-level HTTP wrapper and one error class.
It is feature-flag-free and does not change any user-visible behaviour
on its own — without later PRs, no caller invokes these methods.
## Linear
-
https://linear.app/chatwoot/issue/PLA-148/pr-2-meta-cloud-api-provider-methods
## What changed
- Add `Whatsapp::Providers::WhatsappCloudCallMethods`
(`enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb`)
wrapping six Meta endpoints:
- `pre_accept_call`, `accept_call`, `reject_call`, `terminate_call` —
`POST /{phone_id}/calls` with the relevant action payload.
- `send_call_permission_request` — `POST /{phone_id}/messages`
interactive `call_permission_request`.
- `initiate_call` — `POST /{phone_id}/calls` with `audio`/`offer`
session.
- Prepend the module into `Whatsapp::Providers::WhatsappCloudService`
only if defined, so OSS continues to work without the enterprise
overlay.
- Add `Voice::CallErrors::NoCallPermission`
(`enterprise/lib/voice/call_errors.rb`) — raised when Meta returns error
code `138006` from `initiate_call`. The remaining call-service errors
(`NotRinging`, `AlreadyAccepted`, `CallFailed`) will land with PR-4.
## How to test
There is no UI in this PR. Smoke-test from a Rails console with a
WhatsApp inbox configured for calling:
```ruby
inbox = Inbox.find(<id>)
svc = inbox.channel.provider_service
svc.respond_to?(:initiate_call) # => true
svc.respond_to?(:send_call_permission_request) # => true
# Optional live calls (require a real phone + Meta call-permission opt-in):
svc.send_call_permission_request('15551234567')
svc.initiate_call('15551234567', '<sdp_offer>')
```
Failure path: `initiate_call` against a contact who has not granted call
permission should raise `Voice::CallErrors::NoCallPermission` with
Meta's user-facing message.
Inverted the previous decision: only the active (accepted) call gets
terminated on pagehide, since its WebRTC session dies with the page and
genuinely can't be rejoined. Ringing calls have no WebRTC state yet —
they're just a row on the backend and a state on Meta — so killing them
on refresh would needlessly hang up on the customer when the agent
intended to recover their UI, not reject the call.
After this, the flow on refresh during a ringing inbound is:
- beforeunload prompt fires (warning the agent)
- pagehide does nothing for ringing calls; active calls still terminate
- new page loads, conversation messages fetch
- seedCallsFromHydratedMessages picks up status='ringing' voice_call
messages and populates the calls store
- FloatingCallWidget reappears with Accept; agent clicks; acceptIncomingCall
fetches the SDP via /whatsapp_calls/:id and the call connects