Reset BE/migration/spec files to base so this PR diffs as a pure UI change.
The deleted migration and the BE-side comment/edits belong on the BE PR
chain (feat/whatsapp-call-incoming-pipeline) rather than the UI PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
finalize_call wasn't writing duration_seconds or end_reason, and the Meta
terminate webhook that would have filled them in is suppressed by the
terminal-state idempotency guard in IncomingCallService#handle_terminate.
The result was completed-call bubbles with no duration. Compute the
duration from started_at at agent-hangup time and stamp end_reason
locally instead of waiting for a webhook that won't apply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Enable channel_voice and set provider_config['source'] = 'embedded_signup' in
the before block so Channel::Whatsapp#voice_enabled? actually returns true;
otherwise the service short-circuits before any handling runs.
- Outbound `connect` only stores the SDP answer and broadcasts
voice_call.outbound_connected; the in_progress / started_at transition has
moved to the separate status=ACCEPTED webhook. Update the existing-call spec
to expect status: 'ringing' and started_at: nil instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Wrap mark_outbound_accepted, accept_outbound_call, and handle_terminate in
call.with_lock so they serialize against Whatsapp::CallService (which holds
call.with_lock across the Meta API call). Without this, an ACCEPTED webhook
arriving mid-terminate could overwrite the freshly-finalized terminal status
back to in_progress and broadcast an erroneous outbound_accepted.
- Drop build_missed_inbound_call. An outbound terminate landing in the tiny
window between provider_service.initiate_call and Call.create! had no local
row, so the fallback materialised an inbound row with the same
provider_call_id and tripped the unique (provider, provider_call_id) index
on the controller's create. The "out-of-order webhook" case it protected is
rare in practice; trade that for eliminating the false-positive collision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Pull Request Template
## Description
Bridges WhatsApp Cloud Calling onto Chatwoot's voice pipeline so agents
can answer and place calls on WhatsApp Cloud inboxes with the same UX as
Twilio voice. Browser ↔ Meta WebRTC is direct (no media-server hop);
Chatwoot owns signalling, recording upload, and `Call`/`Message`
lifecycle state.
Companion PRs:
- FE: #14346 (`feat/whatsapp-call-ui`) — UI consumer for these
endpoints.
- Specs: #14357 (`feat/whatsapp-call-meta-bridge-specs`) — backend test
coverage.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## What changed
- **API** — new `Api::V1::Accounts::WhatsappCallsController` with `show
/ accept / reject / terminate / initiate / upload_recording`.
Conversation-level Pundit auth. `initiate` is gated on the channel being
embedded-signup voice-enabled. Permission-blocked initiations (Meta
error 138006) return **422** with `{ status: 'permission_requested' |
'permission_pending' }` and trigger a throttled opt-in template send
under a conversation lock.
- **State machine** — `Whatsapp::CallService` handles accept / reject /
terminate with call-level locking. Meta failures are wrapped as
`Voice::CallErrors::CallFailed` so the controller renders 422 instead of
leaking 500s.
- **Webhooks** — `Whatsapp::IncomingCallService` processes Meta
connect/terminate events. Pins `setup:active` on outbound answers,
materialises a missed-call record when terminate arrives before connect
(out-of-order delivery), and broadcasts `voice_call.*` events to the
assignee's pubsub stream when assigned, otherwise account-wide.
- **Provider** — `Whatsapp::Providers::WhatsappCloudService` adds
`pre_accept_call / accept_call / reject_call / terminate_call /
initiate_call / send_call_permission_request`. Uses configurable
`WHATSAPP_API_VERSION` (default v22) since Calls API needs v17+ and the
OSS `phone_id_path` is locked at v13 for legacy `/messages`
compatibility.
- **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.
## How to test
1. Set up a WhatsApp Cloud Embedded Signup inbox; flip
`provider_config.calling_enabled = true` (Calls tab in inbox settings —
ships with FE PR #14346).
2. Place a real call from a phone number that has previously messaged
the business → expect `voice_call.incoming` cable + `Call` row +
`voice_call` Message bubble.
3. Click Accept (FE PR) → `/whatsapp_calls/:id/accept` 200, audio flows
browser ↔ Meta, recording uploads on hangup, Whisper transcript appears
within ~10s.
4. From Chatwoot, click outbound → `/whatsapp_calls/initiate` 200 if the
contact is opted-in, otherwise **422** with `status:
'permission_requested'` and a template send (FE shows a banner instead
of an error toast).
5. Refresh during a ringing call → call survives on Meta, FE seeds it
back from the conversation cache, agent can still accept.
6. Refresh during an active call → `pagehide` beacon terminates the call
on Meta with `status: 'completed'` (no orphan).
## Checklist
- [x] Code follows project style (RuboCop / ESLint clean)
- [x] Self-review done
- [x] Hard-to-understand areas commented
- [ ] Documentation update (architecture doc lives in companion branch)
- [x] No new warnings
- [x] Tests live in companion PR #14357
- [x] Existing tests pass locally
- [x] Dependent changes merged downstream (FE #14346 consumes these
endpoints)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A concurrent WhatsApp message webhook for the same wa_id could win the
(inbox_id, source_id) insert; the call path then re-raised RecordNotUnique
and the outer rescue mislabeled it as a duplicate provider_call_id,
silently dropping the connect. Handle the race inside ensure_contact_inbox!
and drop the now-redundant outer rescue so unrelated unique violations
surface instead of being swallowed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Description
This pull request addresses issue #11948, where inline images embedded
in emails (such as those sent from Outlook) are not rendered correctly
if the Content-Disposition header is missing.
The solution ensures that images referenced via cid: in the HTML body
are correctly identified and rewritten using url_for.
Fixes#11948
## Type of change
Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Added test: detects image inline attachment by cid reference when
Content-Disposition is missing
## 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
- [ ] My changes generate no new warnings
- [X] 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: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Adds label support to contact import and export so teams can carry
approved contact labels through CSV workflows. Imports accept a `labels`
column with labels that already exist in the account; multiple labels
should be entered as a quoted comma-separated CSV value, for example
`"customer,vip"`.
Imports are additive: they add labels to contacts and do not remove
labels already on a contact. Removing a label from the CSV row or
leaving the `labels` cell blank will not clear existing contact labels.
To remove a label, edit the contact directly.
## Closes
- Closes#8535
## How to test
1. Create a few contact labels in the account, such as `customer`,
`vip`, and `lead`.
2. Go to Contacts -> Import contacts and download the sample CSV.
3. Import contacts with a `labels` column. Use a single label like
`lead`, or quote multiple labels like `"customer,vip"`.
4. Confirm imported contacts are created with the expected labels.
5. Re-import an existing contact with a new label and confirm the new
label is added without removing existing labels.
6. Try a row with an unknown label, such as `"vip,unknown_label"`, and
confirm only that row is rejected in the failed records CSV while the
other valid rows are imported.
7. Export contacts and confirm the CSV includes a `labels` column with
comma-separated approved labels.
## What changed
- Contact exports include approved `labels` in the default CSV columns.
This adds a new default export column for CSV consumers.
- Contact imports parse `labels` as comma-separated values inside the
CSV cell.
- Imported labels are validated against labels that already exist in the
account.
- Rows with unknown labels are rejected with an `Unknown labels: ...`
error; valid rows in the same import continue to process.
- Imported labels are additive and do not remove existing contact
labels.
- Label application during import does not dispatch an additional
per-contact update event.
- The sample CSV includes an import-safe `labels` column. The modal
keeps the existing generic CSV import copy.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
skip documents that fail with ActiveRecord errors possibly due to
stale/corrupt data and not crash scheduler
How did we find out about this error?
before October 28th, 2025, we did not have url normalisation.
so we had document rows as:
id: 123 `https://example.com` status: `in_progress` --> likely stuck
crawl
id 234: `https://example.com/` status: `available`
When the schedule sync job ran, it ran an `document.update!(sync_status:
:syncing, last_sync_attempted_at: Time.current)` on the 234 one since it
was `available`
now `update!` runs `before_validation :normalize_external_link`
so `https://example.com/` became `https://example.com`
which invalidated:
`validates :external_link, uniqueness: { scope: :assistant_id }`
so the scheduler crashed.
This PR logs the skipped ones with their errors and continues to pick
other documents to scheduler doesn't crash
## 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.
spec
## 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
Multi-agent / module-state correctness
- Hoist WhatsApp outbound init lock to module scope so header + contact-panel
buttons share one guard; add an active-session guard so a second click
returns { status: 'locked' } instead of cleanup()-ing the live call.
- isLocalWhatsappCall() filter on voice_call.outbound_connected and
voice_call.ended cable handlers — account-wide broadcasts no longer
feed foreign SDP into this tab's PeerConnection or stop its recorder.
- Permission-flow path (200 with no call id) now releases the
prepareOutboundOffer() mic + RTCPeerConnection instead of leaving the
mic indicator stuck on.
- Drop the intentionallyClosing guard around sendWhatsappTerminateBeacon
so a hangup-then-close race still terminates Meta's side (beacon
endpoint is idempotent).
- Distinguish locked init from permission_requested in callers to avoid
a false "call initiated" alert.
Provider routing
- joinCall outbound short-circuit now scoped to WhatsApp-like calls so
FloatingCallWidget's auto-join for outbound Twilio still works.
- isWhatsappLikeCall(callId-keyed) so calls seeded by message.updated /
refresh path (which lack provider metadata) route to the WhatsApp flow.
- syncConversationCallVisibility per-call filter via shouldShowCall, so
outbound calls aren't ripped from under the caller on assignee change.
- removeCallsForConversation tears down each active call via
teardownByProvider — WhatsApp gets cleanupWhatsappSession (closes pc,
stops recorder/mic) instead of a Twilio-only endClientCall.
- await reject before dismissing in rejectIncomingCall so a failing reject
keeps the call surfaced for retry.
Per-bubble overhead
- Split useCallSession into the root-mount hook + a lightweight
useCallActions for components like VoiceCall.vue that just need state +
actions without registering global window/Twilio listeners. Globals
attach once via a refcount, dismissed-call sids live at module scope so
the seed watcher can't re-add a locally dismissed ringing call.
Lookup correctness
- /contacts/:id/conversations accepts an optional inbox_id filter; the
WhatsApp call button passes inboxId so a contact's older WhatsApp
thread doesn't fall outside the BE's 20-row cap.
Twilio lifecycle / security
- Defer accepted_by_agent claim to the participant-join webhook so a
failed agent device init doesn't leave the call ringing-but-claimed
with no recovery path. mark_agent_joined still raises 409 if another
agent has already claimed.
- Verify X-Twilio-Signature on recording_status — the controller fetches
the recording with channel auth credentials, so an unsigned POST
could coerce credential-bearing requests to an attacker-controlled host.
Legacy data
- Migration to delete orphaned inboxes whose channel_type still says
'Channel::Voice' after the model was removed. The polymorphic
belongs_to :channel lookup on those rows otherwise crashes the inbox
serializer with `uninitialized constant Channel::Voice`.
A retried terminate webhook for an answered short call (duration=0)
would re-enter handle_terminate, find call.in_progress? false, fall
through to no_answer, and corrupt a completed call's status. Bail when
the call is already terminal so retries don't recompute the status.
Inbound WhatsApp calls were matching contacts by stripping `+` from the
raw from_number, bypassing the BR/AR wa_id normalization that
Whatsapp::IncomingMessageServiceHelpers#processed_waid runs on the
messaging path. A wa_id whose webhook variant differs from the stored
normalized source_id (notably Brazil's added "9") would miss the
existing ContactInbox and create a fresh contact/conversation,
fragmenting history. Route the voice path through the same
Whatsapp::PhoneNumberNormalizationService so the lookup is consistent.
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>
Embed templates interpolate regex captures from user-authored article
URLs into HTML attribute values. CommonMark's angle-bracket link
destination syntax allows characters that the capture regexes don't
filter, so the unescaped substitution could produce malformed attribute
output. Escaping at substitution time keeps the render deterministic
regardless of the URL.
### How was this tested?
Added specs.
Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/)
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token.
Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`.
Tested locally: the same PAT returns the full triage list
Changes
----
- Switch to custom token
- Add a discord alert for new advisories
- Switch to python