Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma ce782409c7 chore(voice): trim redundant comments on backend voice files
Pass through pla-150 to drop comments that just describe what the next
line already does:
- whatsapp_calls_controller: removed the Twilio-comparison note (the
  ensure_calling_enabled body says it), the SDP-forwarding tagline, and
  tightened the 138006/transport-error/wamid blocks.
- call.rb: dropped trivial annotations on STATUSES / TERMINAL_STATUSES;
  the names already say what they are.
- send_on_channel_service: collapsed the two-line voice_call rationale
  into one — the surrounding context covers the rest.

No behavior change. 33/33 enterprise call specs still pass; rubocop clean.
2026-05-04 15:24:34 +07:00
Tanmay Deep Sharma 092d59333c chore(voice): rubocop modifier-if on inbox jbuilder voice_enabled line 2026-05-04 14:20:07 +07:00
Tanmay Deep Sharma 1bcc25f95c fix(voice): guard audio attachment after_create_commit hooks on file presence
Both enqueue_audio_transcription and broadcast_message_update_for_audio
fired immediately after Attachment#save without checking whether a file
was actually attached. The broadcast path then walked through
Message#push_event_data → Attachment#audio_metadata → file.metadata,
which is nil when no file is attached, raising NoMethodError on
file.metadata[:width].

This was visible to:
- Captain::OpenAiMessageBuilderService specs that build audio attachments
  without a file (intentional — those specs test attachment_parts logic,
  not file storage). 4 specs failing, all crashing inside the broadcast
  callback rather than in the code under test.
- Any future callsite that creates an audio attachment shell before
  attaching the blob (we don't have one in production today, but the
  callbacks should be defensive).

Add `return unless file.attached?` to both. The transcription job has
nothing to transcribe without a file; the broadcast carries no useful
audio info before the file lands. The callbacks fire again — correctly —
once the blob is attached and committed in a subsequent transaction.
2026-05-04 14:15:38 +07:00
Tanmay Deep Sharma 442631102c fix(voice): stub Channel::Voice for legacy inbox rows after DropChannelVoice migration 2026-05-02 16:06:36 +07:00
Tanmay Deep Sharma 78c615fe42 chore(voice): align whatsapp_spec to incoming-pipeline (specs go to specs PR) 2026-05-02 14:49:21 +07:00
Tanmay Deep Sharma 907011e280 chore(voice): split spec changes out to feat/whatsapp-call-specs (sibling PR) 2026-05-02 14:48:41 +07:00
Tanmay Deep Sharma a766ebf642 chore(voice): align FE files to incoming-pipeline base (UI changes consolidated to PR #14346) 2026-05-02 14:40:06 +07:00
Tanmay Deep Sharma 0e0e0868d7 chore(voice): split FE changes out to feat/whatsapp-call-ui (PR #14346) 2026-05-02 14:31:13 +07:00
Tanmay Deep Sharma d9077c64d3 fix(voice): robust accept-with-fetch + remote-end recording race + single-duration bubble + audio playback signals 2026-05-01 18:29:54 +07:00
Tanmay Deep Sharma 1abb51992b fix(voice): fall back to content_attributes.data for duration when call payload lacks it 2026-05-01 17:50:02 +07:00
Tanmay Deep Sharma 0282156821 fix(audio): allow audio attachments to serve inline so <audio> can play call recordings 2026-05-01 17:47:04 +07:00
Tanmay Deep Sharma 257a9129d7 fix(voice): create missed-call record when terminate webhook arrives before connect 2026-05-01 17:33:05 +07:00
Tanmay Deep Sharma 1ce316efc7 ui(voice): use ButtonV4 for header WhatsApp call button to match action toolbar styling 2026-05-01 17:27:57 +07:00
Tanmay Deep Sharma ba94d8c2b0 fix(transcription): drop Whisper temperature to 0 to suppress hallucinations 2026-05-01 16:31:48 +07:00
Tanmay Deep Sharma 6e11bebeae fix(audio): resolve real duration for MediaRecorder WebM blobs via end-seek trick 2026-05-01 11:16:55 +07:00
Tanmay Deep Sharma 069f25f88a fix(voice): await remote-end upload before store removeCall to prevent sync cleanup race 2026-05-01 11:15:09 +07:00
Tanmay Deep Sharma 63c193358c fix(voice): render audio + transcript + duration on call bubble; upload recording on remote-end too 2026-05-01 11:06:53 +07:00
Tanmay Deep Sharma 7663e2ac30 fix(voice): merge call store entries so cable + message.created races don't drop provider/sdp/caller 2026-05-01 10:54:47 +07:00
Tanmay Deep Sharma 0567655840 fix(voice): outbound STUN, mute toggle, skip voice_call bubble in send pipeline 2026-04-30 20:43:35 +07:00
Tanmay Deep Sharma c25ed094fa fix(voice): pipe Meta's remote audio to a hidden audio element so the agent hears the contact 2026-04-30 20:17:19 +07:00
Tanmay Deep Sharma 112a8f9e9e feat(voice): branch contact-panel call button on inbox provider for WhatsApp 2026-04-30 20:14:21 +07:00
Tanmay Deep Sharma 723d416ce3 fix(voice): bump WhatsApp Cloud Calling endpoints to v22 (OSS phone_id_path is locked at v13) 2026-04-30 19:54:31 +07:00
Tanmay Deep Sharma f3df486ce6 feat(voice): add WhatsApp calling enable toggle to inbox configuration 2026-04-30 19:39:19 +07:00
Tanmay Deep Sharma c7b6a87e77 feat(voice): wire WhatsApp call UI on top of existing Twilio session flow 2026-04-30 19:30:43 +07:00
Tanmay Deep Sharma 19d91e47f1 fix(voice): rescue transport errors during permission_request send 2026-04-30 19:11:14 +07:00
Tanmay Deep Sharma 1c945899c7 fix(whatsapp): restrict voice_enabled? to embedded-signup whatsapp_cloud channels 2026-04-30 19:10:06 +07:00
Tanmay Deep Sharma 2c025755e2 fix(voice): wrap Meta transport errors during accept as CallFailed 2026-04-30 18:52:51 +07:00
Tanmay Deep Sharma 0618e6e229 fix(voice): close lock-window gaps in accept, throttle, and call_attributes_changed? 2026-04-30 18:46:09 +07:00
Tanmay Deep Sharma cbef2d4e66 refactor(voice): memoize provider_service accessor in whatsapp calls controller 2026-04-30 18:26:43 +07:00
Tanmay Deep Sharma cb93aba55c refactor(voice): split call validations into single-purpose before_actions 2026-04-30 18:14:29 +07:00
Tanmay Deep Sharma beef43f2bf refactor(voice): hoist validations to before_action and centralize call-error rendering 2026-04-30 18:04:25 +07:00
Tanmay Deep Sharma cbd3f759b5 fix(voice): order accept guards correctly and validate contact phone upstream 2026-04-30 17:46:24 +07:00
Tanmay Deep Sharma a7fdc45977 fix(voice): bail on Meta failures and restrict initiate to WhatsApp inboxes 2026-04-30 17:26:01 +07:00
Tanmay Deep Sharma 41e3ea0d87 fix(voice): record permission_request wamid for context-based reply matching 2026-04-30 17:11:55 +07:00
Tanmay Deep Sharma fd22017292 Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 17:10:45 +07:00
Tanmay Deep Sharma 555894793e fix(voice): match permission reply to originating conversation via context.id 2026-04-30 17:10:34 +07:00
Tanmay Deep Sharma 7c87d3d150 fix(voice): lock call state transitions, branch terminate on connection state, atomic recording upload, rescue CallFailed in initiate 2026-04-30 16:54:47 +07:00
Tanmay Deep Sharma c37d1b1d6b Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 16:51:42 +07:00
Tanmay Deep Sharma e5a03d08ea fix(voice): resolve contact via source_id and i18n call message bubble 2026-04-30 16:51:29 +07:00
Tanmay Deep Sharma bc80985f55 refactor(voice): align WhatsappCallsController with Chatwoot jbuilder + concern conventions 2026-04-30 16:38:08 +07:00
Tanmay Deep Sharma 83cd568ca2 feat(voice): add WhatsApp Cloud Calling agent service, controller and routes 2026-04-30 16:24:39 +07:00
Tanmay Deep Sharma f32a7b3f77 refactor(voice): drop redundant CallMessageBuilder.update_status! class delegate 2026-04-30 16:13:53 +07:00
Tanmay Deep Sharma ba0fcbfc7f refactor(voice): tighten WhatsApp call pipeline and broadcast hyphenated terminate status 2026-04-30 16:04:39 +07:00
Tanmay Deep Sharma 546193de2a refactor(voice): tighten predicate and trim STUN comment 2026-04-30 15:35:58 +07:00
Tanmay Deep Sharma 67b522c433 refactor(voice): expose voice_enabled? on Channel::Whatsapp for duck-typed checks 2026-04-30 15:34:08 +07:00
Tanmay Deep Sharma f5eca9d1d0 refactor(voice): inline single-use helpers in WhatsApp call services 2026-04-30 15:28:41 +07:00
Tanmay Deep Sharma 5c4db3a6bc refactor(voice): extract finalize_status! helper to DRY incoming call lifecycle 2026-04-30 15:19:21 +07:00
Tanmay Deep Sharma 792dc359d4 fix(voice): tighten WhatsApp call lifecycle handling and per-call locking 2026-04-30 15:07:51 +07:00
Tanmay Deep Sharma 2b0aaa237f feat(voice): add WhatsApp inbound call webhook pipeline 2026-04-30 14:59:46 +07:00
Tanmay Deep Sharma 48022833ea refactor(voice): address review feedback on WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma e2b3965a08 refactor(voice): raise typed Voice::CallErrors::CallFailed instead of StandardError 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 17e25fa3c4 test(voice): cover WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 37963cc654 fix(voice): coerce non-Hash parsed_response in initiate_call error path 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma c7f5277277 feat(voice): add WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
1124c1b4c2 feat(voice): Wire Twilio voice flow through unified call model (#14091)
Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 11:25:39 +04:00
TonyandGitHub cd9c8e3303 fix: skip self-mention notification in private notes (#14318)
When an agent mentions themselves in a private note, they no longer
receive a redundant notification for their own mention.

Closes: #4096

# Pull Request Template

## Description

Agents who mention themselves in a private note no longer receive a
conversation_mention notification. Previously, the mention service would
generate a notification for every mentioned user without checking
whether the sender and the
mentioned user were the same person.
2026-04-29 23:27:14 +05:30
80fccbc526 fix: render slack emoji shortcodes as unicode characters (#12928)
This PR fixes an issue where Slack emojis are rendered as text
shortcodes (e.g. 🚀) instead of the actual emoji characters in
Chatwoot messages.

It introduces a new EmojiFormatter class that uses the emoji-data
mapping to convert shortcodes to unicode characters.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-04-29 23:19:52 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
bcdb73502e chore(deps): bump addressable from 2.8.7 to 2.9.0 (#14019)
Bumps [addressable](https://github.com/sporkmonger/addressable) from
2.8.7 to 2.9.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md">addressable's
changelog</a>.</em></p>
<blockquote>
<h2>Addressable 2.9.0 <!-- raw HTML omitted --></h2>
<ul>
<li>fixes ReDoS vulnerability in Addressable::Template#match (fixes
incomplete
remediation in 2.8.10)</li>
</ul>
<h2>Addressable 2.8.10 <!-- raw HTML omitted --></h2>
<ul>
<li>fixes ReDoS vulnerability in Addressable::Template#match</li>
</ul>
<h2>Addressable 2.8.9 <!-- raw HTML omitted --></h2>
<ul>
<li>Reduce gem size by excluding test files (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/569">#569</a>)</li>
<li>No need for bundler as development dependency (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/571">#571</a>,
<a
href="https://github.com/sporkmonger/addressable/commit/5fc1d93">5fc1d93</a>)</li>
<li>idna/pure: stop building the useless <code>COMPOSITION_TABLE</code>
(removes the <code>Addressable::IDNA::COMPOSITION_TABLE</code> constant)
(<a
href="https://redirect.github.com/sporkmonger/addressable/issues/564">#564</a>)</li>
</ul>
<p><a
href="https://redirect.github.com/sporkmonger/addressable/issues/569">#569</a>:
<a
href="https://redirect.github.com/sporkmonger/addressable/pull/569">sporkmonger/addressable#569</a>
<a
href="https://redirect.github.com/sporkmonger/addressable/issues/571">#571</a>:
<a
href="https://redirect.github.com/sporkmonger/addressable/pull/571">sporkmonger/addressable#571</a>
<a
href="https://redirect.github.com/sporkmonger/addressable/issues/564">#564</a>:
<a
href="https://redirect.github.com/sporkmonger/addressable/pull/564">sporkmonger/addressable#564</a></p>
<h2>Addressable 2.8.8 <!-- raw HTML omitted --></h2>
<ul>
<li>Replace the <code>unicode.data</code> blob by a ruby constant (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/561">#561</a>)</li>
<li>Allow <code>public_suffix</code> 7 (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/558">#558</a>)</li>
</ul>
<p><a
href="https://redirect.github.com/sporkmonger/addressable/issues/561">#561</a>:
<a
href="https://redirect.github.com/sporkmonger/addressable/pull/561">sporkmonger/addressable#561</a>
<a
href="https://redirect.github.com/sporkmonger/addressable/issues/558">#558</a>:
<a
href="https://redirect.github.com/sporkmonger/addressable/pull/558">sporkmonger/addressable#558</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/sporkmonger/addressable/commit/0c3e8589b23d4402903a9b4e1fdeba4e43c52ca4"><code>0c3e858</code></a>
Revving version and changelog</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/91915c1f7aafa3e2c9f42e2f4e21d948c7a861b8"><code>91915c1</code></a>
Fixing additional vulnerable paths</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/a091e39ff02fc321b21dea3a0df585bef2ba3744"><code>a091e39</code></a>
Add many more adversarial test cases to ensure we don't have any ReDoS
regres...</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/463a819665a3b85ce5ce894c90bd7bfa3b9d2e15"><code>463a819</code></a>
Regenerate gemspec on newer rubygems</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/0afcb0b9672bee301e5e96ed850fec05b2fcabb0"><code>0afcb0b</code></a>
Improve from O(n^2) to O(n)</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/c87f768f22ab00376ed2f8cb106f59c9d0652d3a"><code>c87f768</code></a>
Fix a ReDoS vulnerability in URI template matching</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/0d7e9b259fb0940d1a85064b04f678a7984409a5"><code>0d7e9b2</code></a>
Fix links for 2.8.9 in CHANGELOG (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/573">#573</a>)</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/e2091200b31553f19248eb871f071852409796f8"><code>e209120</code></a>
Update version, gemspec, and CHANGELOG for 2.8.9 (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/572">#572</a>)</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/387587492b6536748ed12a11c3fdb44a48885f28"><code>3875874</code></a>
Reduce gem size by excluding test files (<a
href="https://redirect.github.com/sporkmonger/addressable/issues/569">#569</a>)</li>
<li><a
href="https://github.com/sporkmonger/addressable/commit/3e57cc6018f94231aabb47fd341acd1b40f1e71a"><code>3e57cc6</code></a>
CI: back to <code>windows-2022</code> for MRI job</li>
<li>Additional commits viewable in <a
href="https://github.com/sporkmonger/addressable/compare/addressable-2.8.7...addressable-2.9.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=addressable&package-manager=bundler&previous-version=2.8.7&new-version=2.9.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-29 22:15:48 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
d634ced4e9 chore(deps-dev): bump uuid from 13.0.0 to 14.0.0 in /tests/playwright (#14294)
Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.0 to 14.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v14.0.0</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>) (<a
href="https://github.com/uuidjs/uuid/commit/dc4ddb87272ed2843faccd130bcc41d492688bd3">dc4ddb8</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>) (<a
href="https://github.com/uuidjs/uuid/commit/f2c235f93059325fa43e1106e624b5291bb523c4">f2c235f</a>)</li>
<li>Use GITHUB_TOKEN for release-please and enable npm provenance (<a
href="https://redirect.github.com/uuidjs/uuid/issues/925">#925</a>) (<a
href="https://github.com/uuidjs/uuid/commit/ffa31383e8e4e1f0b4e22e504561272041b8738c">ffa3138</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md">uuid's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>Security</h3>
<ul>
<li>Fixes <a
href="https://github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq">GHSA-w5hq-g745-h8pq</a>:
<code>v3()</code>, <code>v5()</code>, and <code>v6()</code> did not
validate that writes would remain within the bounds of a caller-supplied
buffer, allowing out-of-bounds writes when an invalid
<code>offset</code> was provided. A <code>RangeError</code> is now
thrown if <code>offset &lt; 0</code> or <code>offset + 16 &gt;
buf.length</code>.</li>
</ul>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li><code>crypto</code> is now expected to be globally defined (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
<li>upgrade minimum supported TypeScript version to 5.4.3, in keeping
with the project's policy of supporting TypeScript versions released
within the last two years</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/uuidjs/uuid/commit/7c1ea087a8149b57380fc8bb7f68c3a215cb6e4b"><code>7c1ea08</code></a>
chore(main): release 14.0.0 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/926">#926</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/3d2c5b0342f0fcb52a5ac681c3d47c13e7444b34"><code>3d2c5b0</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/f2c235f93059325fa43e1106e624b5291bb523c4"><code>f2c235f</code></a>
fix!: expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/529ef0899f5dd503d2ee90d690585d63d78bc212"><code>529ef08</code></a>
chore: upgrade TypeScript and fixup types (<a
href="https://redirect.github.com/uuidjs/uuid/issues/927">#927</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/086fd7976f11433edf9ac80be876b3ad243fe087"><code>086fd79</code></a>
chore: update dependencies (<a
href="https://redirect.github.com/uuidjs/uuid/issues/933">#933</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/dc4ddb87272ed2843faccd130bcc41d492688bd3"><code>dc4ddb8</code></a>
feat!: drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/0f1f9c9c9cedbae5a1d363d5406c5dfbabe81404"><code>0f1f9c9</code></a>
chore: switch to Biome for parsing and linting (<a
href="https://redirect.github.com/uuidjs/uuid/issues/932">#932</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/e2879e64bf125add903c1eff6e0860542c605013"><code>e2879e6</code></a>
chore: use maintained version of npm-run-all (<a
href="https://redirect.github.com/uuidjs/uuid/issues/930">#930</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/ffa31383e8e4e1f0b4e22e504561272041b8738c"><code>ffa3138</code></a>
fix: Use GITHUB_TOKEN for release-please and enable npm provenance (<a
href="https://redirect.github.com/uuidjs/uuid/issues/925">#925</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/0423d49df2dc8efc300c804731d25f4d7e0fccc4"><code>0423d49</code></a>
docs: remove obsolete v1 option notes (<a
href="https://redirect.github.com/uuidjs/uuid/issues/915">#915</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for uuid since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uuid&package-manager=npm_and_yarn&previous-version=13.0.0&new-version=14.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-29 22:03:50 +05:30
e723c6b6f2 fix: Prevent platform banners cloud check from breaking app boot (#14321)
The `ChatwootApp.chatwoot_cloud?` gate on the platform banners route in
#13943 reads `InstallationConfig` from the database. Because `routes.rb`
is evaluated during `Rails.application.initialize!`, this ran before the
database existed on a fresh setup, breaking `bundle exec rake db:create`
in CI and first-time installs with `ActiveRecord::NoDatabaseError: We
could not find your database: chatwoot_test`.

The route is now always mounted, and the cloud check moved to where the
database is guaranteed to be available — the controller
(`before_action`) and the super admin sidebar partial.

Closes the CI failure introduced by #13943.

## How to test
1. Drop your local databases: `bundle exec rake db:drop`
2. Run `bundle exec rake db:create` — it should succeed (previously
failed with `NoDatabaseError`)
3. Bring the DB back: `bundle exec rake db:setup`
4. On a non-cloud install, visit `/super_admin/platform_banners` —
should 404, and the sidebar entry should be hidden
5. With `DEPLOYMENT_ENV=cloud` configured (cloud install), the page and
sidebar entry should work as before

## What changed
- `config/routes.rb` — always mount `resources :platform_banners` (no DB
call at boot)
- `app/controllers/super_admin/platform_banners_controller.rb` —
`before_action` raises `ActionController::RoutingError` (404) when not
on Chatwoot Cloud
- `app/views/super_admin/application/_navigation.html.erb` — hides the
sidebar entry on non-cloud installs

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-29 19:30:06 +04:00
5325e05143 feat: Add platform-wide status banners for outage notifications (#13943)
Adds a platform-wide status banner system to notify all users about
external service outages. Super Admins can create, edit, and manage
banners via the Super Admin console. Banners support markdown for links
and are dismissible by users.


<img width="1099" height="236" alt="image"
src="https://github.com/user-attachments/assets/047a7994-d885-4a8a-b9c4-aeb32f15474a"
/>




## How to test
1. Set `ENABLE_PLATFORM_BANNERS=true` in your environment
2. Go to Super Admin → Platform Banners
3. Create a banner with a message like: `Elevated error rates from Meta
APIs. [Check status](https://metastatus.com)`
4. Select a banner type: `info` (blue), `warning` (amber), or `error`
(red)
5. Visit the dashboard — the banner should appear at the top
6. Click "Dismiss" — the banner hides and stays dismissed across page
reloads
7. Deactivate the banner in Super Admin — it disappears on next page
load

## What changed
- New `PlatformBanner` model with `banner_message`, `banner_type`
(info/warning/error), and `active` flag
- Super Admin CRUD via Administrate (controller, dashboard, routes,
sidebar icon)
- `DashboardController` serves active banners via `globalConfig`
- `StatusBanner.vue` component renders banners with markdown support and
per-banner localStorage dismiss
- Feature gated behind `ENABLE_PLATFORM_BANNERS` env var

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-29 17:18:38 +04:00
Aakash BakhleandGitHub c09206c22e fix: flaky time based document sync spec (#14320)
fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/111572/workflows/210e3618-301a-4a8f-a411-826214965441/jobs/146263
2026-04-29 17:36:14 +05:30
91 changed files with 2088 additions and 863 deletions
+1
View File
@@ -22,6 +22,7 @@ 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 --##
+5 -3
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.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -349,6 +349,7 @@ 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
@@ -676,7 +677,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (7.0.5)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
@@ -1075,6 +1076,7 @@ DEPENDENCIES
fcm
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
+8 -1
View File
@@ -80,10 +80,17 @@ 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
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
}
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'
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,20 @@
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
View File
@@ -48,3 +48,5 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse
end
end
MessageFinder.prepend_mod_with('MessageFinder')
+3
View File
@@ -3,6 +3,7 @@ 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';
@@ -27,6 +28,7 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
StatusBanner,
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
@@ -137,6 +139,7 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
async joinClientCall({ to, conversationId, callSid }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
leaveConference({ inboxId, conversationId, callSid }) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
params: { conversation_id: conversationId, call_sid: callSid },
})
.then(r => r.data);
}
@@ -35,10 +35,16 @@ const emit = defineEmits([
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
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 unreadCount = computed(() => props.chat.unread_count);
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
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',
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',
};
return classMap[props.color];
@@ -113,6 +113,7 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -30,12 +30,10 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const { call } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
@@ -0,0 +1,63 @@
<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,3 +1,5 @@
<!-- 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';
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
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 showMetaSection = computed(() => {
return (
@@ -42,8 +42,8 @@ export function useCallSession() {
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
@@ -66,6 +66,7 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
callSid,
});
callsStore.setCallActive(callSid);
@@ -1,5 +1,6 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
+9 -7
View File
@@ -23,11 +23,11 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
const call = message?.call || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
callSid: call.provider_call_id,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
@@ -60,9 +60,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
conversationId,
callStatus: status,
callSid,
});
const isNewCall =
status === 'ringing' &&
@@ -307,34 +307,21 @@ export const mutations = {
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
[types.UPDATE_MESSAGE_CALL_STATUS](
_state,
{ conversationId, callStatus }
{ conversationId, callStatus, callSid }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
const message = (chat.messages || []).find(
m =>
m.content_type === CONTENT_TYPES.VOICE_CALL &&
m.call?.provider_call_id === callSid
);
if (!message?.call) return;
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
message.call = { ...message.call, status: callStatus };
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
@@ -2,55 +2,18 @@ import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
it('does nothing if no matching voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
@@ -59,6 +22,7 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
@@ -66,7 +30,7 @@ describe('#mutations', () => {
});
});
it('updates the last voice call message status', () => {
it('updates only the voice call message matching the given callSid', () => {
const state = {
allConversations: [
{
@@ -75,12 +39,12 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA111', status: 'ringing' },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA222', status: 'ringing' },
},
],
},
@@ -89,34 +53,15 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA111',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
expect(state.allConversations[0].messages[0].call.status).toBe(
'in-progress'
);
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
it('preserves existing call fields when updating status', () => {
const state = {
allConversations: [
{
@@ -125,8 +70,11 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
call: {
provider_call_id: 'CA123',
status: 'ringing',
direction: 'incoming',
duration_seconds: null,
},
},
],
@@ -136,12 +84,13 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA123',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA123',
status: 'in-progress',
direction: 'incoming',
duration_seconds: null,
});
});
@@ -152,8 +101,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages).toEqual([]);
});
it('does nothing if matching message has no call object yet', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA-OTHER' },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
callSid: 'CA-MISSING',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA-OTHER',
});
});
});
});
@@ -52,7 +52,6 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
@@ -24,6 +24,7 @@ const {
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv,
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
} = window.globalConfig || {};
const state = {
@@ -49,6 +50,7 @@ const state = {
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
activePlatformBanners: activePlatformBanners || [],
};
export const getters = {
+2
View File
@@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
+12
View File
@@ -104,11 +104,23 @@ 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,
+9
View File
@@ -40,6 +40,15 @@ class Channel::Whatsapp < ApplicationRecord
'Whatsapp'
end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['calling_enabled'].present?
end
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
+18
View File
@@ -0,0 +1,18 @@
# == Schema Information
#
# Table name: platform_banners
#
# id :bigint not null, primary key
# active :boolean default(TRUE)
# banner_message :text not null
# banner_type :integer default("info"), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class PlatformBanner < ApplicationRecord
enum :banner_type, { info: 0, warning: 1, error: 2 }
validates :banner_message, presence: true
scope :active, -> { where(active: true) }
end
+2 -1
View File
@@ -46,7 +46,8 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
# voice_call bubbles are call status indicators, not deliverable messages
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
def validate_target_channel
+6
View File
@@ -50,6 +50,8 @@ class Messages::MentionService
def generate_notifications_for_mentions(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id|
next if self_mention?(user_id)
NotificationBuilder.new(
notification_type: 'conversation_mention',
user: User.find(user_id),
@@ -60,6 +62,10 @@ class Messages::MentionService
end
end
def self_mention?(user_id)
message.sender_type == 'User' && user_id.to_i == message.sender_id
end
def add_mentioned_users_as_participants(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id|
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
@@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
process_response(response, message)
end
end
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
@@ -142,3 +142,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
@@ -12,3 +12,5 @@ json.private message.private
json.source_id message.source_id
json.sender message.sender.push_event_data if message.sender
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
json.set! :call, message.call.push_event_data if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
@@ -174,6 +174,10 @@
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-megaphone-line" viewBox="0 0 20 20">
<path d="M15.8333 3.33334C16.0543 3.33334 16.2663 3.42114 16.4226 3.57742C16.5789 3.7337 16.6667 3.94566 16.6667 4.16668V15.8333C16.6667 16.0544 16.5789 16.2663 16.4226 16.4226C16.2663 16.5789 16.0543 16.6667 15.8333 16.6667C14.3967 16.6667 12.9408 16.2917 11.6575 15.5267C10.5833 14.8842 9.6675 14.0317 8.9575 13.0217L8.33333 14.8933C8.26541 15.0989 8.12416 15.2722 7.93666 15.3803C7.74917 15.4884 7.52804 15.5239 7.31583 15.48L5.67083 15.0392C5.45946 14.9842 5.27514 14.8558 5.15098 14.677C5.02683 14.4982 4.97074 14.2812 4.99249 14.065L5.5375 10.8333H4.16667C3.94565 10.8333 3.72369 10.7455 3.56741 10.5893C3.41113 10.433 3.32333 10.221 3.32333 10V7.50001C3.32333 7.27899 3.41113 7.06703 3.56741 6.91075C3.72369 6.75447 3.94565 6.66668 4.16667 6.66668H8.0625C9.43167 4.56834 12.2483 3.33334 15.8333 3.33334ZM7.60667 8.33334H5V9.16668H7.29167L7.60667 8.33334ZM15 5.07501C12.4167 5.24168 10.3483 6.23168 9.2675 7.82168L7.97833 10.8975L6.63417 14.0508L7.12 14.1817L7.78917 12.1817L8.335 11.3683C8.335 11.3683 8.37167 11.4092 8.42667 11.4783C9.265 12.5483 10.3342 13.4267 11.565 14.0617C12.5833 14.6275 13.7608 14.935 15 14.99V5.07501Z" fill="currentColor"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

@@ -16,6 +16,7 @@ as defined by the routes in the `admin/` namespace
users: 'icon-user-follow-line',
platform_apps: 'icon-apps-2-line',
agent_bots: 'icon-robot-line',
platform_banners: 'icon-megaphone-line',
}
%>
@@ -33,6 +34,7 @@ as defined by the routes in the `admin/` namespace
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
+13
View File
@@ -0,0 +1,13 @@
# Allow audio attachments (call recordings, voice notes) to serve inline so the
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
# forces Content-Disposition: attachment for any MIME outside the default allowlist
# (images + PDF), which makes the browser download instead of play.
Rails.application.config.active_storage.content_types_allowed_inline += %w[
audio/webm
audio/ogg
audio/mpeg
audio/mp4
audio/x-m4a
audio/wav
audio/x-wav
]
+11
View File
@@ -114,6 +114,13 @@ en:
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
calls:
not_enabled: 'Calling is not enabled for this inbox'
no_recording: 'No recording file provided'
no_message: 'Call has no associated message'
sdp_offer_required: 'sdp_offer is required'
contact_phone_required: 'Contact phone number is required'
permission_request_failed: 'Failed to send call permission request'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -243,6 +250,10 @@ en:
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
+16
View File
@@ -215,6 +215,21 @@ Rails.application.routes.draw do
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :whatsapp_calls, only: [:show] do
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
collection do
post :initiate
end
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -653,6 +668,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member, action: :destroy_avatar
end
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
resources :platform_banners
resource :instance_status, only: [:show]
resource :settings, only: [:show] do
@@ -0,0 +1,11 @@
class CreatePlatformBanners < ActiveRecord::Migration[7.1]
def change
create_table :platform_banners do |t|
t.text :banner_message, null: false
t.integer :banner_type, null: false, default: 0
t.boolean :active, default: true, null: false
t.timestamps
end
end
end
+8
View File
@@ -1087,6 +1087,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.datetime "updated_at", null: false
end
create_table "platform_banners", force: :cascade do |t|
t.text "banner_message", null: false
t.integer "banner_type", default: 0, null: false
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false
t.string "name", null: false
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
def voice_call_inbox?
@conversation.inbox.channel.try(:voice_enabled?)
end
end
@@ -10,36 +10,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
call = resolve_call!
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
id: call.conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
call = resolve_call!
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: call.conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
def resolve_call!
sid = params[:call_sid].presence
raise ActionController::ParameterMissing, :call_sid if sid.blank?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
conversation = fetch_conversation_by_display_id
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
.find_by!(provider_call_id: sid)
end
def set_voice_inbox_for_conference
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
call = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
conversation_id: call.conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
call_sid: call.provider_call_id,
conference_sid: call.conference_sid
}
end
@@ -0,0 +1,162 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
PERMISSION_REQUEST_THROTTLE = 5.minutes
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
before_action :ensure_recording_present, only: :upload_recording
before_action :ensure_call_message, only: :upload_recording
rescue_from Voice::CallErrors::NotRinging,
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
def accept
call_service.accept
end
def reject
call_service.reject
end
def terminate
call_service.terminate
end
def upload_recording
@upload_status = @call.message.with_lock { attach_recording_idempotently }
end
def initiate
@call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
def call_service
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
end
def provider_service
@provider_service ||= @conversation.inbox.channel.provider_service
end
def set_call
@call = Current.account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
end
def set_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
end
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
end
def ensure_contact_phone
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
def ensure_recording_present
return if params[:recording].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
end
def ensure_call_message
return if @call.message.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
end
def attach_recording_idempotently
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
'uploaded'
end
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
end
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
# template (throttled, behind a conversation lock to prevent double-send).
def render_permission_request
status = nil
@conversation.with_lock do
if permission_request_throttled?
status = 'permission_pending'
next
end
sent = send_permission_request_safely
if sent
record_permission_request_wamid(sent)
status = 'permission_requested'
else
status = 'failed'
end
end
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
render json: { status: status }
end
def permission_request_throttled?
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Treat transport errors as a falsy return so we render 422 rather than 500.
def send_permission_request_safely
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
nil
end
# Stash the outbound wamid so the reply webhook can match context.id back here.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
)
@conversation.update!(additional_attributes: attrs)
end
def render_call_error(error)
render_could_not_create_error(error.message)
end
end
@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
end
def call_twiml
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
call = resolve_call
render xml: conference_twiml(call)
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
if event.nil?
Rails.logger.info(
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
)
return head :no_content
end
conversation = find_conversation_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
Voice::Conference::Manager.new(
conversation: conversation,
call: call,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
@@ -80,91 +78,83 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
def find_call_for_agent
sid = params[:call_sid].presence
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
inbox_calls.find_by!(provider_call_id: sid)
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
def sync_outbound_leg(call_sid:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
call
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
def inbox_calls
Call.where(inbox_id: inbox.id, provider: :twilio)
end
def conference_twiml(conference_sid, agent_leg)
def conference_twiml(call)
conference_sid = ensure_conference_sid!(call)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
participant_label: participant_label_for(twilio_from)
)
end
end.to_s
end
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def participant_label_for(from_number)
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
'contact'
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
def set_inbox!
@@ -0,0 +1,5 @@
module Enterprise::MessageFinder
def conversation_messages
super.includes(call: [:contact, { inbox: :channel }])
end
end
@@ -0,0 +1,41 @@
module Enterprise::Webhooks::WhatsappEventsJob
def handle_message_events(channel, params)
return handle_call_events(channel, params) if call_event?(params)
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
super
end
private
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
def contact_sender_id(params)
return nil if call_event?(params)
super
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def call_permission_reply?(params)
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
end
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
def handle_call_events(channel, params)
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
calls.each do |call_payload|
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
with_lock(lock_key, 30.seconds) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
end
end
end
def handle_call_permission_reply(channel, params)
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
end
end
+76 -3
View File
@@ -29,11 +29,16 @@
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
# All valid call statuses
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -41,7 +46,7 @@ class Call < ApplicationRecord
belongs_to :inbox
belongs_to :conversation
belongs_to :contact
belongs_to :message, optional: true
belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording
@@ -52,4 +57,72 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
def default_conference_sid
"conf_account_#{account_id}_call_#{id}"
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
def self.default_ice_servers
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
[{ urls: urls }]
end
def direction_label
DISPLAY_DIRECTION[direction]
end
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end
+8
View File
@@ -0,0 +1,8 @@
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
# the upstream DropChannelVoice migration moved voice fields onto
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
# so jbuilder `.try` chains short-circuit instead of raising.
class Channel::Voice < ApplicationRecord
self.table_name = 'channel_twilio_sms'
end
@@ -3,13 +3,30 @@ module Enterprise::Concerns::Attachment
included do
after_create_commit :enqueue_audio_transcription
# Broadcast the message update so the FE bubble picks up the new audio
# attachment immediately. Without this, the FE has to wait until Whisper
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
# the bubble never gets the audio at all.
after_create_commit :broadcast_message_update_for_audio
end
private
def enqueue_audio_transcription
return unless file_type.to_sym == :audio
return unless file.attached?
Messages::AudioTranscriptionJob.perform_later(id)
end
def broadcast_message_update_for_audio
return unless file_type.to_sym == :audio
return unless message
# Without an attached file, the message serializer's audio_metadata path
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
return unless file.attached?
message.reload.send_update_event
end
end
@@ -13,6 +13,12 @@ module Enterprise::Conversation
super + %w[sla_policy_id]
end
# Surface call lifecycle changes to the FE: writes to additional_attributes
# call_status/call_direction should rebroadcast conversation_updated.
def allowed_keys?
super || call_attributes_changed?
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
@@ -25,20 +31,18 @@ module Enterprise::Conversation
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
attrs_change = previous_changes['additional_attributes']
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
def call_attributes_changed?
return false if previous_changes['additional_attributes'].blank?
# Compare before/after values for call keys — checking key presence alone
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
before, after = previous_changes['additional_attributes']
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
end
end
@@ -1,4 +1,18 @@
module Enterprise::Message
def self.prepended(base)
base.class_eval do
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
end
end
def push_event_data
data = super
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
data
end
private
def mark_pending_conversation_as_open_for_human_response
@@ -0,0 +1,96 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
# Calls API + the call_permission_request interactive message both require Graph
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
def accept_call(call_id, sdp_answer)
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
end
def reject_call(call_id)
call_api('reject_call', call_action_body(call_id, 'reject'))
end
def terminate_call(call_id)
call_api('terminate_call', call_action_body(call_id, 'terminate'))
end
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
return nil
end
response.parsed_response
end
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def calls_phone_id_path
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
body
end
def call_api(action_name, body)
url = "#{calls_phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
response.success?
end
def permission_request_body(to_phone_number, body_text)
{
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
type: 'interactive',
interactive: {
type: 'call_permission_request',
action: { name: 'call_permission_request' },
body: { text: body_text }
}
}.to_json
end
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}.to_json
end
def process_initiate_call_response(response)
return response.parsed_response if response.success?
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
error_code = parsed.dig('error', 'code')
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
raise Voice::CallErrors::CallFailed, error_msg
end
end
@@ -67,7 +67,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
parameters: {
model: WHISPER_MODEL,
file: file,
temperature: 0.4
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
# higher temperatures produce phantom phrases that look like real transcripts.
temperature: 0.0
}
)
transcribed_text = response['text']
@@ -1,90 +1,52 @@
class Voice::CallMessageBuilder
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
new(
conversation: conversation,
direction: direction,
payload: payload,
user: user,
timestamps: timestamps
).perform!
end
def initialize(conversation:, direction:, payload:, user:, timestamps:)
@conversation = conversation
@direction = direction
@payload = payload
@user = user
@timestamps = timestamps
def initialize(call)
@call = call
end
def perform!
validate_sender!
message = latest_message
message ? update_message!(message) : create_message!
call.message || create_message!
end
def update_status!(status:, agent: nil, duration_seconds: nil)
message = call.message
return unless message
patch = {
'status' => status&.to_s&.tr('_', '-'),
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
'duration_seconds' => duration_seconds
}.compact
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
message
end
private
attr_reader :conversation, :direction, :payload, :user, :timestamps
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
end
def update_message!(message)
message.update!(
message_type: message_type,
content_attributes: { 'data' => base_payload },
sender: sender
)
end
attr_reader :call
def create_message!
params = {
content: 'Voice Call',
message_type: message_type,
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call',
content_attributes: { 'data' => base_payload }
content_attributes: { 'data' => build_data_payload }
}
Messages::MessageBuilder.new(sender, conversation, params).perform
end
def base_payload
@base_payload ||= begin
data = payload.slice(
:call_sid,
:status,
:call_direction,
:conference_sid,
:from_number,
:to_number
).stringify_keys
data['call_direction'] = direction
data['meta'] = {
'created_at' => timestamps[:created_at] || current_timestamp,
'ringing_at' => timestamps[:ringing_at] || current_timestamp
}.compact
data
end
end
def message_type
direction == 'outbound' ? 'outgoing' : 'incoming'
Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
def sender
return user if direction == 'outbound'
conversation.contact
call.outgoing? ? call.accepted_by_agent : call.contact
end
def validate_sender!
return unless direction == 'outbound'
raise ArgumentError, 'Agent sender required for outbound calls' unless user
end
def current_timestamp
@current_timestamp ||= Time.zone.now.to_i
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
def build_data_payload
{
'call_id' => call.id,
'call_sid' => call.provider_call_id,
'call_source' => call.provider,
'call_direction' => call.direction_label,
'status' => call.display_status
}
end
end
@@ -1,94 +0,0 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
@conversation = conversation
@call_sid = call_sid
@message_call_sid = message_call_sid || call_sid
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
end
def perform
ActiveRecord::Base.transaction do
attrs = refreshed_attributes
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
sync_voice_call_message!(attrs)
end
conversation
end
private
def refreshed_attributes
attrs = (conversation.additional_attributes || {}).dup
attrs['call_direction'] = direction
attrs['call_status'] ||= 'ringing'
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
end
def sync_voice_call_message!(attrs)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
to_number: target_number_for(direction)
},
user: agent_for(attrs),
timestamps: {
created_at: attrs.dig('meta', 'initiated_at'),
ringing_at: attrs.dig('meta', 'ringing_at')
}
)
end
def origin_number_for(current_direction)
return outbound_origin if current_direction == 'outbound'
from_number.presence || inbox_number
end
def target_number_for(current_direction)
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
to_number || conversation.contact&.phone_number
end
def agent_for(attrs)
agent_id = attrs['agent_id']
return nil unless agent_id
agent = conversation.account.users.find_by(id: agent_id)
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
agent
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
def outbound_origin
inbox_number || from_number
end
def inbox_number
conversation.inbox&.channel&.phone_number
end
end
@@ -1,66 +1,44 @@
class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
pattr_initialize [:call!]
def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status)
return unless Call::STATUSES.include?(status)
return if call.status == status
current_status = conversation.additional_attributes&.dig('call_status')
return if current_status == status
apply_status(status, duration: duration, timestamp: timestamp)
update_message(status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
end
private
def apply_status(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup
attrs['call_status'] = status
def apply_call_updates!(status, duration:, timestamp:)
attrs = { status: status }
ts = timestamp || now_seconds
if status == 'in-progress'
attrs['call_started_at'] ||= timestamp || now_seconds
elsif TERMINAL_STATUSES.include?(status)
attrs['call_ended_at'] = timestamp || now_seconds
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
if status == 'in_progress'
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
started_at = Time.zone.at(ts)
attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
elsif Call::TERMINAL_STATUSES.include?(status)
call.ended_at = ts
attrs[:meta] = call.meta
attrs[:duration_seconds] = resolved_duration(duration, ts)
end
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
call.update!(attrs)
end
def resolved_duration(attrs, provided_duration, timestamp)
def resolved_duration(provided_duration, timestamp)
return provided_duration if provided_duration
return unless call.started_at
started_at = attrs['call_started_at']
return unless started_at && timestamp
[timestamp - started_at.to_i, 0].max
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = status
message.update!(content_attributes: data)
[timestamp - call.started_at.to_i, 0].max
end
def now_seconds
current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
Time.zone.now.to_i
end
end
@@ -1,71 +1,71 @@
class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
pattr_initialize [:call!, :event!, :participant_label]
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
def process
case event
when 'start'
ensure_conference_sid!
mark_ringing!
when 'join'
mark_in_progress! if agent_participant?
join_agent! if agent_participant?
when 'leave'
handle_leave!
when 'end'
finalize_conference!
finalize!
end
end
private
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
end
def mark_ringing!
return if current_status
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
return unless call.status == 'ringing'
status_manager.process_status_update('ringing')
end
def mark_in_progress!
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
def join_agent!
user_id = extract_user_id
call.update!(accepted_by_agent_id: user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
def extract_user_id
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
return unless match
return unless match[2].to_i == call.account_id
match[1].to_i
end
def handle_leave!
case current_status
case call.status
when 'ringing'
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
when 'in-progress'
status_manager.process_status_update('completed', timestamp: current_timestamp)
status_manager.process_status_update('no_answer', timestamp: now)
when 'in_progress'
status_manager.process_status_update('completed', timestamp: now)
end
end
def finalize_conference!
return if %w[completed no-answer failed].include?(current_status)
def finalize!
return if Call::TERMINAL_STATUSES.include?(call.status)
status_manager.process_status_update('completed', timestamp: current_timestamp)
end
def current_status
conversation.additional_attributes&.dig('call_status')
status_manager.process_status_update('completed', timestamp: now)
end
def agent_participant?
participant_label.to_s.start_with?('agent')
participant_label.to_s.start_with?('agent-')
end
def current_timestamp
def now
Time.zone.now.to_i
end
end
@@ -1,5 +0,0 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
end
end
@@ -1,99 +1,102 @@
class Voice::InboundCallBuilder
attr_reader :account, :inbox, :from_number, :call_sid
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
def self.perform!(account:, inbox:, from_number:, call_sid:)
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
provider: provider, extra_meta: extra_meta).perform!
end
def initialize(account:, inbox:, from_number:, call_sid:)
@account = account
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
end
def perform!
timestamp = current_timestamp
existing = find_existing_call
return existing if existing
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox)
conversation.reload
update_conversation!(conversation, timestamp)
build_voice_message!(conversation, timestamp)
conversation
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
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
# creating with a colliding source_id under a different contact would raise
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
def ensure_contact_inbox!
sid = source_id_for_provider
existing = inbox.contact_inboxes.find_by(source_id: sid)
return existing if existing
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
end
def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank?
end
end
def ensure_contact_inbox!(contact)
ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
) do |record|
record.source_id = from_number
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
def source_id_for_provider
provider == :whatsapp ? from_number.to_s.delete_prefix('+') : from_number
end
def resolve_conversation!(contact, contact_inbox)
if inbox.lock_to_single_conversation
reusable = account.conversations
.where(contact_id: contact.id, inbox_id: inbox.id)
.where.not(status: :resolved)
.order(last_activity_at: :desc)
.first
return reusable if reusable
end
end
def find_conversation
return if call_sid.blank?
account.conversations.includes(:contact).find_by(identifier: call_sid)
end
def create_conversation!(contact, contact_inbox)
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: :open,
identifier: call_sid
status: :open
)
end
def update_conversation!(conversation, timestamp)
attrs = {
'call_direction' => 'inbound',
'call_status' => 'ringing',
'conference_sid' => Voice::Conference::Name.for(conversation),
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(contact, conversation)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'inbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conversation.additional_attributes['conference_sid'],
from_number: from_number,
to_number: inbox.channel&.phone_number
},
timestamps: { created_at: timestamp, ringing_at: timestamp }
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)
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
# `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
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox)
conversation.reload
conference_sid = Voice::Conference::Name.for(conversation)
call_sid = initiate_call!
update_conversation!(conversation, call_sid, conference_sid, timestamp)
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
{ conversation: conversation, call_sid: call_sid }
call = create_call!(conversation, call_sid)
message = Voice::CallMessageBuilder.new(call).perform!
call.update!(message_id: message.id)
call
end
end
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
end
def initiate_call!
inbox.channel.initiate_call(
to: contact.phone_number
)[:call_sid]
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
end
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
attrs = {
'call_direction' => 'outbound',
'call_status' => 'ringing',
'agent_id' => user.id,
'conference_sid' => conference_sid,
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(conversation, call_sid)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'outbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conference_sid,
from_number: inbox.channel&.phone_number,
to_number: contact.phone_number
},
user: user,
timestamps: { created_at: timestamp, ringing_at: timestamp }
contact: contact,
accepted_by_agent: user,
provider: :twilio,
direction: :outgoing,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
call.update!(conference_sid: call.default_conference_sid)
call
end
end
@@ -1,35 +1,24 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!]
pattr_initialize [:call!]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
return call.conference_sid if call.conference_sid.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
call.update!(accepted_by_agent: user)
end
def end_conference
client = conversation.inbox.channel.client
return if call.conference_sid.blank?
client = call.inbox.channel.client
client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
end
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
'queued' => 'ringing',
'initiated' => 'ringing',
'ringing' => 'ringing',
'in-progress' => 'in-progress',
'inprogress' => 'in-progress',
'answered' => 'in-progress',
'in-progress' => 'in_progress',
'inprogress' => 'in_progress',
'answered' => 'in_progress',
'completed' => 'completed',
'busy' => 'no-answer',
'no-answer' => 'no-answer',
'busy' => 'no_answer',
'no-answer' => 'no_answer',
'failed' => 'failed',
'canceled' => 'failed'
}.freeze
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
normalized_status = normalize_status(call_status)
return if normalized_status.blank?
conversation = account.conversations.find_by(identifier: call_sid)
return unless conversation
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
return unless call
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
).process_status_update(
Voice::CallStatus::Manager.new(call: call).process_status_update(
normalized_status,
duration: payload_duration,
timestamp: payload_timestamp
@@ -0,0 +1,61 @@
class Whatsapp::CallPermissionReplyService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
reply_data = extract_reply_data
return unless reply_data&.dig(:accepted)
conversation = find_requesting_conversation(reply_data[:context_id])
return unless conversation
clear_permission_flag(conversation)
broadcast_permission_granted(conversation.contact, conversation)
end
private
def extract_reply_data
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
reply = message&.dig(:interactive, :call_permission_reply)
return unless reply
accepted = reply[:response] == 'accept'
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
{ from_number: message[:from], accepted: accepted, context_id: message.dig(:context, :id) }
end
# Match the reply to the conversation whose request message it actually points
# at (interactive replies carry context.id = our outbound wamid). Recency-based
# lookup would broadcast to the wrong thread when a contact has multiple
# parallel pending requests.
def find_requesting_conversation(context_id)
return if context_id.blank?
inbox.conversations
.where.not(status: :resolved)
.where("additional_attributes ->> 'call_permission_request_message_id' = ?", context_id)
.first
end
def clear_permission_flag(conversation)
attrs = (conversation.additional_attributes || {}).except(
'call_permission_requested_at', 'call_permission_request_message_id'
)
conversation.update!(additional_attributes: attrs)
end
def broadcast_permission_granted(contact, conversation)
ActionCable.server.broadcast(
"account_#{inbox.account_id}",
{
event: 'voice_call.permission_granted',
data: {
account_id: inbox.account_id, conversation_id: conversation.id,
contact_name: contact.name, contact_phone: contact.phone_number
}
}
)
end
end
@@ -0,0 +1,102 @@
class Whatsapp::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
def accept
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
# All side effects under the lock so a concurrent terminate cannot finalize
# the call between status update and the message/conversation/broadcast writes.
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
end
def reject
call.with_lock do
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
finalize_call('failed')
end
call
end
def terminate
call.with_lock do
next if call.terminal?
invoke_provider!(:terminate_call)
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
finalize_call(call.in_progress? ? 'completed' : 'no_answer')
end
call
end
private
def transition_to_in_progress!
# Order matters: in_progress and terminal both make ringing? false, so we have to
# branch on in_progress? first to surface the distinct AlreadyAccepted state.
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
invoke_provider!(:pre_accept_call, sdp_answer)
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
# Raise on Meta failure (bool false or transport error) so callers bail before
# finalizing local state — otherwise we'd mark a still-active call as ended
# and broadcast voice_call.ended while Meta thinks it's live.
def invoke_provider!(method, *)
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id, *)
raise Voice::CallErrors::CallFailed, "Meta #{method} failed" unless success
rescue Voice::CallErrors::CallFailed
raise
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
end
def finalize_call(status)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
call.update!(status: status, meta: meta)
update_message_status(status)
update_conversation_call_status(call.display_status)
broadcast(:ended, status: call.display_status)
end
def update_message_status(status)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent)
end
def update_conversation_call_status(status)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => status)
)
end
def broadcast(event, **extra)
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -0,0 +1,122 @@
class Whatsapp::IncomingCallService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
end
private
def handle_event(payload)
case payload[:event]
when 'connect' then handle_connect(payload)
when 'terminate' then handle_terminate(payload)
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
end
end
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return create_inbound_call(payload) if call.nil?
return accept_outbound_call(call, payload) if call.outgoing?
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
rescue ActiveRecord::RecordNotUnique
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{payload[:id]}"
end
def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp)
call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
update_conversation(call)
broadcast_incoming(call, sdp_offer)
end
def accept_outbound_call(call, payload)
return if call.in_progress? || call.terminal?
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
update_call!(call, 'in_progress',
started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end
def handle_terminate(payload)
# Webhooks can arrive out of order (terminate before connect under tunnel/network
# delays). Materialise a missed-call record so the contact's "called and hung up"
# still surfaces in the dashboard instead of being silently dropped.
call = Call.whatsapp.find_by(provider_call_id: payload[:id]) || build_missed_inbound_call(payload)
return unless call
duration = payload[:duration]&.to_i
status = answered?(call, duration) ? 'completed' : 'no_answer'
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
update_call!(call, status, duration_seconds: duration, end_reason: payload[:terminate_reason], meta: meta)
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
# No connect was ever processed (webhook reordering or never delivered) — build a
# bare Call+Message for the contact so the UI shows the missed-call bubble.
def build_missed_inbound_call(payload)
Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'ice_servers' => Call.default_ice_servers }
)
rescue ActiveRecord::RecordNotUnique
Call.whatsapp.find_by(provider_call_id: payload[:id])
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
end
def update_call!(call, status, **attrs)
call.update!(status: status, **attrs)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
duration_seconds: attrs[:duration_seconds])
update_conversation(call)
end
def update_conversation(call)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge(
'call_status' => call.display_status, 'call_direction' => call.direction_label
)
)
end
# Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
broadcast(call, 'voice_call.incoming',
streams: token ? [token] : account_streams,
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
end
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
end
def account_streams
["account_#{inbox.account_id}"]
end
def base_payload(call)
{ account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
provider: 'whatsapp', conversation_id: call.conversation_id }
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -0,0 +1,5 @@
json.status 'calling'
json.call_id @call.provider_call_id
json.id @call.id
json.message_id @message.id
json.provider 'whatsapp'
@@ -0,0 +1,2 @@
json.id @call.id
json.status @call.display_status
@@ -0,0 +1 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -0,0 +1,2 @@
json.id @call.id
json.status @call.display_status
@@ -0,0 +1,2 @@
json.id @call.id
json.status @upload_status
@@ -0,0 +1,24 @@
contact = call.conversation&.contact
json.id call.id
json.call_id call.provider_call_id
json.provider call.provider
json.status call.display_status
json.direction call.direction_label
json.conversation_id call.conversation_id
json.inbox_id call.inbox_id
json.message_id call.message_id
json.accepted_by_agent_id call.accepted_by_agent_id
json.elapsed_seconds(call.started_at ? (Time.current - call.started_at).to_i : 0)
json.sdp_offer call.meta&.dig('sdp_offer')
json.ice_servers(call.meta&.dig('ice_servers') || Call.default_ice_servers)
if contact
json.caller do
json.name contact.name
json.phone contact.phone_number
json.avatar contact.avatar_url
end
else
json.caller({})
end
+11
View File
@@ -0,0 +1,11 @@
module Voice::CallErrors
# Meta WhatsApp Cloud Calling error code returned when the contact has not
# granted call permission yet. See `initiate_call` in
# Enterprise::Whatsapp::Providers::WhatsappCloudService.
NO_CALL_PERMISSION_CODE = 138_006
class NoCallPermission < StandardError; end
class CallFailed < StandardError; end
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
end
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Integrations::Slack::EmojiFormatter
def self.format(text)
return text if text.blank?
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
short_code = Regexp.last_match(1)
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
# rubocop:disable Rails/DynamicFindBy
emoji = Emoji.find_by_alias(short_code)
# rubocop:enable Rails/DynamicFindBy
emoji ? emoji.raw : match
end
end
end
@@ -35,7 +35,7 @@ module Integrations::Slack::SlackMessageHelper
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
content: formatted_message_content,
external_source_id_slack: params[:event][:ts],
private: private_note?,
sender: resolved_sender,
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
[nil, nil, nil]
end
def formatted_message_content
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
Integrations::Slack::EmojiFormatter.format(text)
end
def private_note?
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
end
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'creates conference and sets identifier' do
it 'resolves the Call by call_sid and invokes the conference service' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(body['conference_sid']).to eq('CF123')
expect(body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
end
it 'does not allow accessing conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
it 'rejects the request when call_sid is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content)
expect(conference_service).not_to have_received(:ensure_conference_sid)
end
it 'does not allow accessing calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end
end
end
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'ends conference and returns success' do
it 'ends the conference for the resolved call' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
end
it 'does not allow ending conferences for conversations from inboxes without access' do
it 'does not allow ending conferences for calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id }
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end
@@ -19,15 +19,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
let(:to_number) { channel.phone_number }
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
instance_double(Voice::InboundCallBuilder)
conversation = create(:conversation, account: account, inbox: inbox)
contact = conversation.contact
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
call.update!(conference_sid: call.default_conference_sid)
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
).and_return(conversation)
).and_return(call)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
@@ -39,64 +47,59 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>')
expect(response.body).to include('<Dial>')
expect(response.body).to include(call.conference_sid)
end
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
expect(Voice::CallSessionSyncService).to receive(:new).with(
hash_including(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: to_number,
direction: 'outbound'
}
)
).and_return(sync_double)
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: call_sid
)
call.update!(conference_sid: call.default_conference_sid)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-api'
}
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>')
expect(response.body).to include(call.conference_sid)
expect(call.reload.parent_call_sid).to be_nil
end
it 'uses the parent call SID when syncing outbound-dial legs' do
it 'records the parent call SID when syncing outbound-dial legs' do
parent_sid = 'CA_parent'
child_sid = 'CA_child'
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
expect(Voice::CallSessionSyncService).to receive(:new).with(
hash_including(
conversation: conversation,
call_sid: child_sid,
message_call_sid: parent_sid,
leg: {
from_number: from_number,
to_number: to_number,
direction: 'outbound'
}
)
).and_return(sync_double)
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: parent_sid
)
call.update!(conference_sid: call.default_conference_sid)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => child_sid,
'ParentCallSid' => parent_sid,
'From' => from_number,
'To' => to_number,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-dial'
}
expect(response).to have_http_status(:ok)
expect(call.reload.parent_call_sid).to eq(parent_sid)
end
it 'raises not found when inbox is not present' do
@@ -62,17 +62,17 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
end
it 'marks the due document as syncing before queueing' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
described_class.new.perform
expect(document.reload).to have_attributes(
@@ -0,0 +1,69 @@
require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
let(:headers) { { 'Content-Type' => 'application/json' } }
before { stub_request(:get, /message_templates/) }
describe 'call action methods' do
it 'POSTs the action body with the SDP answer and returns true on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept',
session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json)
.to_return(status: 200, body: '{}', headers: headers)
expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true
end
it 'returns false when Meta responds with a non-success status' do
stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers)
expect(service.reject_call('WACALL')).to be false
end
end
describe '#send_call_permission_request' do
it 'returns the parsed body on success' do
stub_request(:post, messages_url)
.with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive'))
.to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers)
expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }])
end
end
describe '#initiate_call' do
it 'returns the parsed body on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }])
end
it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do
stub_request(:post, calls_url).to_return(
status: 400,
body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json,
headers: headers
)
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission')
end
it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do
stub_request(:post, calls_url).to_return(status: 502, body: '<html>502 Bad Gateway</html>',
headers: { 'Content-Type' => 'text/html' })
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call')
end
end
end
@@ -7,7 +7,6 @@ RSpec.describe Voice::InboundCallBuilder do
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
let(:call_sid) { 'CA1234567890abcdef' }
before do
@@ -17,105 +16,108 @@ RSpec.describe Voice::InboundCallBuilder do
def perform_builder
described_class.perform!(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
)
end
context 'when no existing conversation matches call_sid' do
it 'creates a new inbound conversation with ringing status' do
conversation = nil
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
context 'when no existing call matches call_sid' do
it 'creates a new conversation and Call with ringing status' do
call = nil
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
attrs = conversation.additional_attributes
expect(conversation.identifier).to eq(call_sid)
expect(attrs['call_direction']).to eq('inbound')
expect(attrs['call_status']).to eq('ringing')
expect(attrs['conference_sid']).to be_present
expect(attrs.dig('meta', 'initiated_at')).to be_present
expect(conversation.contact.phone_number).to eq(from_number)
aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.provider).to eq('twilio')
expect(call.direction).to eq('incoming')
expect(call.status).to eq('ringing')
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.meta['initiated_at']).to be_present
expect(call.contact.phone_number).to eq(from_number)
end
end
it 'creates a single voice_call message marked as incoming' do
conversation = perform_builder
voice_message = conversation.messages.voice_calls.last
it 'creates a voice_call message matched to the call and linked via message_id' do
call = perform_builder
voice_message = call.conversation.messages.voice_calls.last
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
data = voice_message.content_attributes['data']
expect(data).to include(
'call_sid' => call_sid,
'status' => 'ringing',
'call_direction' => 'inbound',
'conference_sid' => conversation.additional_attributes['conference_sid'],
'from_number' => from_number,
'to_number' => inbox.channel.phone_number
)
expect(data['meta']['created_at']).to be_present
expect(data['meta']['ringing_at']).to be_present
aggregate_failures do
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.call).to eq(call)
end
end
it 'sets the contact name to the phone number for new callers' do
conversation = perform_builder
call = perform_builder
expect(conversation.contact.name).to eq(from_number)
expect(call.contact.name).to eq(from_number)
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = perform_builder
conversation = call.conversation
perform_builder
expect(conversation.identifier).to be_nil
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
end
end
context 'when a conversation already exists for the call_sid' do
let(:contact) { create(:contact, account: account, phone_number: from_number) }
context 'when a Call already exists for the call_sid' do
let(:existing_call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: call_sid
)
end
it 'returns the existing call without creating a duplicate' do
existing_call
expect { perform_builder }.not_to change(Call, :count)
expect(perform_builder).to eq(existing_call)
end
end
context 'when a ContactInbox already exists for the source_id (different contact)' do
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
let!(:original_contact_inbox) do
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
end
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
call = perform_builder
expect(call.contact).to eq(original_contact)
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
end
end
context 'when the inbox has lock_to_single_conversation enabled' do
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_conversation) do
create(
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
)
end
let(:existing_message) do
create(
:message,
account: account,
inbox: inbox,
conversation: existing_conversation,
message_type: :incoming,
content_type: :voice_call,
sender: contact,
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
)
let!(:existing_open_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
end
it 'reuses the conversation without creating a duplicate' do
existing_message
expect { perform_builder }.not_to change(account.conversations, :count)
existing_conversation.reload
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
before { inbox.update!(lock_to_single_conversation: true) }
it 'reuses the most recent non-resolved conversation' do
call = nil
expect { call = perform_builder }.not_to change(account.conversations, :count)
expect(call.conversation).to eq(existing_open_conversation)
end
it 'updates the existing voice call message instead of creating a new one' do
existing_message
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
updated_message = existing_conversation.reload.messages.voice_calls.last
data = updated_message.content_attributes['data']
expect(data['status']).to eq('ringing')
expect(data['call_direction']).to eq('inbound')
it 'still creates a new Call and voice_call message on the reused conversation' do
expect { perform_builder }.to change(Call, :count).by(1)
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
end
end
end
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
allow(inbox).to receive(:channel).and_return(channel)
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
allow(Voice::Conference::Name).to receive(:for).and_call_original
end
describe '.perform!' do
it 'creates a conversation and voice call message' do
conversation_count = account.conversations.count
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
it 'creates a conversation, Call, and voice_call message' do
call = nil
expect do
call = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
result = described_class.perform!(
aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.direction).to eq('outgoing')
expect(call.status).to eq('ringing')
expect(call.accepted_by_agent_id).to eq(user.id)
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
voice_message = call.conversation.messages.voice_calls.last
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.message_type).to eq('outgoing')
expect(voice_message.call).to eq(call)
end
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
expect(account.conversations.count).to eq(conversation_count + 1)
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
conversation = result[:conversation].reload
attrs = conversation.additional_attributes
aggregate_failures do
expect(result[:call_sid]).to eq(call_sid)
expect(conversation.identifier).to eq(call_sid)
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
expect(attrs['agent_id']).to eq(user.id)
expect(attrs['conference_sid']).to be_present
voice_message = conversation.messages.voice_calls.last
expect(voice_message.message_type).to eq('outgoing')
message_data = voice_message.content_attributes['data']
expect(message_data).to include(
'call_sid' => call_sid,
'conference_sid' => attrs['conference_sid'],
'from_number' => channel.phone_number,
'to_number' => contact.phone_number
)
end
expect(call.conversation.identifier).to be_nil
expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
end
it 'raises an error when contact is missing a phone number' do
@@ -79,19 +79,5 @@ RSpec.describe Voice::OutboundCallBuilder do
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end
end
end
@@ -4,8 +4,17 @@ describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:call) do
create(
:call,
account: account,
inbox: channel.inbox,
conversation: conversation,
contact: conversation.contact
)
end
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation) }
let(:service) { described_class.new(call: call) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
@@ -14,42 +23,37 @@ describe Voice::Provider::Twilio::ConferenceService do
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
it 'returns existing sid if present on the Call' do
call.update!(conference_sid: 'CF_EXISTING')
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
expect(service.ensure_conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.reload.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
it 'sets accepted_by_agent on the Call' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
expect(call.reload.accepted_by_agent_id).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
it 'completes in-progress conferences matching the call conference_sid' do
call.update!(conference_sid: 'CF123_FRIENDLY')
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(conferences_proxy).to receive(:list).with(friendly_name: 'CF123_FRIENDLY', status: 'in-progress').and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
@@ -57,5 +61,11 @@ describe Voice::Provider::Twilio::ConferenceService do
expect(conf_context).to have_received(:update).with(status: 'completed')
end
it 'no-ops when call has no conference_sid' do
allow(twilio_client).to receive(:conferences)
service.end_conference
expect(twilio_client).not_to have_received(:conferences)
end
end
end
@@ -4,43 +4,47 @@ require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
let(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do
Conversation.create!(
account_id: account.id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
end
let!(:call) do
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
end
let(:message) do
conversation.messages.create!(
let!(:message) do
msg = conversation.messages.create!(
account_id: account.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'ringing' } }
)
call.update!(message_id: msg.id)
msg
end
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
it 'updates conversation and last voice message with call status' do
# Ensure records are created after stub setup
conversation
message
it 'updates the Call and touches the linked message on status transition' do
previous_updated_at = message.updated_at
travel 1.second
described_class.new(
account: account,
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
call_status: 'completed'
).perform
conversation.reload
call.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
expect(call.status).to eq('completed')
expect(message.updated_at).to be > previous_updated_at
end
it 'normalizes busy to no-answer' do
conversation
message
it 'normalizes busy to no_answer on the Call' do
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'busy'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
expect(call.reload.status).to eq('no_answer')
end
it 'no-ops when conversation not found' do
it 'no-ops when no Call matches the provided call_sid' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error
@@ -0,0 +1,97 @@
require 'rails_helper'
describe Whatsapp::CallPermissionReplyService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') }
let(:request_wamid) { 'wamid.permission_request_abc' }
let!(:conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open,
additional_attributes: {
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => request_wamid
})
end
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
end
def reply_params(response:, context_id: request_wamid)
interactive = { type: 'call_permission_reply',
call_permission_reply: { response: response, is_permanent: false } }
message = { from: '15550001111', type: 'interactive', interactive: interactive }
message[:context] = { id: context_id } if context_id
{ entry: [{ changes: [{ value: { messages: [message] } }] }] }
end
it 'clears both permission flags and broadcasts voice_call.permission_granted on accept' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
attrs = conversation.reload.additional_attributes
expect(attrs).not_to include('call_permission_requested_at')
expect(attrs).not_to include('call_permission_request_message_id')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.permission_granted',
data: hash_including(conversation_id: conversation.id))
)
end
it 'is a no-op when the contact rejected the request' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform
expect(conversation.reload.additional_attributes).to include('call_permission_requested_at')
expect(ActionCable.server).not_to have_received(:broadcast)
end
it 'is a no-op when calling is disabled on the channel' do
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
channel.save!
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
expect(ActionCable.server).not_to have_received(:broadcast)
end
it 'matches the originating conversation by context.id when the contact has multiple pending requests' do
other_request_wamid = 'wamid.permission_request_xyz'
other_open = create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
status: :open,
additional_attributes: {
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => other_request_wamid
})
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: other_request_wamid)).perform
# The reply pointed at other_open's request — it should be the cleared one, not `conversation`
expect(other_open.reload.additional_attributes).not_to include('call_permission_request_message_id')
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(data: hash_including(conversation_id: other_open.id))
)
end
it 'is a no-op when the reply has no context.id' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: nil)).perform
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
@@ -0,0 +1,192 @@
require 'rails_helper'
describe Whatsapp::IncomingCallService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:from_number) { '15550001111' }
let(:provider_call_id) { 'wacid_abc' }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
end
def call_payload(event:, **extra)
{ calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] }
end
context 'when calling is disabled on the channel' do
it 'is a no-op' do
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
channel.save!
expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform }
.not_to change(Call, :count)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
call = Call.last
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
provider_call_id: provider_call_id)
expect(call.meta['sdp_offer']).to eq(sdp_offer)
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
)
end
end
describe 'outbound connect (existing call)' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id)
end
it 'transitions the call to in_progress and broadcasts voice_call.outbound_connected with the SDP answer' do
allow(ActionCable.server).to receive(:broadcast)
sdp_answer = "v=0\r\na=setup:actpass\r\n"
params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'in_progress', started_at: be_present)
expect(call.meta['sdp_answer']).to include('a=setup:active')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.outbound_connected')
)
end
end
describe 'terminate' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id)
end
it 'marks the call completed when the call had been answered' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally')
expect(call.ended_at).to be_present
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.ended', data: hash_including(status: 'completed'))
)
end
it 'marks unanswered ringing calls as no_answer' do
call.update!(status: 'ringing')
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('no_answer')
end
end
describe 'duplicate inbound connect' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: provider_call_id)
end
it 'logs and ignores rather than treating it as outbound' do
allow(Rails.logger).to receive(:info)
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: 'sdp_x', sdp_type: 'offer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'ringing')
expect(Rails.logger).to have_received(:info).with(/Duplicate inbound connect/)
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
describe 'connect arriving after terminal status' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'completed', provider_call_id: provider_call_id)
end
it 'does not reopen a completed outbound call' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: 'late_sdp', sdp_type: 'answer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('completed')
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
describe 'unanswered outbound call terminate' do
let!(:agent) { create(:user, account: account) }
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'ringing',
accepted_by_agent: agent, provider_call_id: provider_call_id)
end
it 'marks the call as no_answer even though accepted_by_agent_id is set' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('no_answer')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.ended', data: hash_including(status: 'no-answer'))
)
end
end
describe 'unknown event' do
it 'logs a warning and does not raise' do
allow(Rails.logger).to receive(:warn)
params = call_payload(event: 'mystery')
expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/)
end
end
describe 'multiple calls in one webhook payload' do
it 'processes every call in the array' do
allow(ActionCable.server).to receive(:broadcast)
params = {
calls: [
{ id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } },
{ id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } }
]
}
expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2)
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
end
end
end
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :call do
association :conversation
account { conversation.account }
inbox { conversation.inbox }
contact { conversation.contact }
provider { :twilio }
direction { :incoming }
status { 'ringing' }
sequence(:provider_call_id) { |n| "CA#{SecureRandom.hex(15)}#{n}" }
end
end
@@ -0,0 +1,20 @@
require 'rails_helper'
describe Integrations::Slack::EmojiFormatter do
describe '.format' do
it 'replaces emoji shortcodes with unicode characters' do
expect(described_class.format('Hello :smile:')).to eq('Hello 😄')
expect(described_class.format('Good job :+1:')).to eq('Good job 👍')
expect(described_class.format('Unknown :unknown_emoji:')).to eq('Unknown :unknown_emoji:')
end
it 'handles nil or empty text' do
expect(described_class.format(nil)).to be_nil
expect(described_class.format('')).to eq('')
end
it 'replaces multiple emojis' do
expect(described_class.format(':smile: :+1:')).to eq('😄 👍')
end
end
end
@@ -165,6 +165,36 @@ describe Messages::MentionService do
end
end
context 'when the message sender mentions themselves' do
it 'skips the sender notification while notifying other mentioned users' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://user/#{second_agent.id}/#{second_agent.name})",
private: true,
sender: first_agent
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when mentioned user is not an inbox member' do
let!(:non_member_user) { create(:user, account: account) }
+1 -1
View File
@@ -30,6 +30,6 @@
"playwright": "^1.56.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
}
}
+5 -5
View File
@@ -57,8 +57,8 @@ importers:
specifier: ^8.59.1
version: 8.59.1(eslint@9.39.4)(typescript@6.0.3)
uuid:
specifier: ^13.0.0
version: 13.0.0
specifier: ^14.0.0
version: 14.0.0
packages:
@@ -645,8 +645,8 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
which@2.0.2:
@@ -1234,7 +1234,7 @@ snapshots:
dependencies:
punycode: 2.3.1
uuid@13.0.0: {}
uuid@14.0.0: {}
which@2.0.2:
dependencies: