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
Aakash BakhleandGitHub 568aae875b feat: wire up auto-sync job backend [AI-150] (#14117)
# Pull Request Template

## Description

- Wires up Controllers to auto-sync job
- adds plan based sync schedule
- a scheduler that runs every hour to check syncable documents
- guards the whole feature behind feature flag by reclaiming
`twilio_content_templates`
- Adds a global and account level cap on how many documents to enqueue
to prevent sudden burst at first run
- some refactor to simplify code
- specs

Fixes # (issue)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

specs and locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-29 14:47:14 +05:30
Sivin VargheseandGitHub 2324a344dc fix: keep agents action visible in teams edit/add view (#14304) 2026-04-29 14:42:37 +05:30
Sivin VargheseandGitHub 7c7d67fd06 fix: show all matches when filtering by multiple labels (#14303) 2026-04-29 14:15:37 +05:30
Sivin VargheseandGitHub b058d84034 fix: prevent Escape from opening formatting toolbar in editor (#14133) 2026-04-29 14:15:08 +05:30
0e122188e9 feat: Add voice calling as a capability on Twilio SMS channel(Enterprise) (#13963)
Voice calling is now a capability on the existing TwilioSms rather than
a separate Voice model. A single Twilio phone number handles both SMS
and voice calls through one inbox.
Fixes
https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel
and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms

**What changed**
- Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms
- Added voice_enabled, twiml_app_sid, api_key_secret columns to
channel_twilio_sms table
  - Dropped channel_voice table (no production data)
- All voice logic lives in Enterprise layer via
prepend_mod_with('Channel::TwilioSms')
- Added Voice settings tab on Twilio SMS inbox settings to
enable/disable voice
  - Validates Twilio number voice capability before provisioning
- Teardown service cleans up TwiML app and credentials when voice is
disabled
- Frontend voice detection uses isVoiceCallEnabled() /
getVoiceCallProvider() helpers — extensible to future providers
  - Gated by channel_voice feature flag

**How to test**

1. Enable feature flag:
Account.find(<id>).enable_features('channel_voice')
2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials →
verify incoming/outgoing calls and SMS work
3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox
→ Voice tab → toggle on → provide API key credentials → verify calls
work
4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted,
credentials cleared, SMS still works
5. Re-enable voice: Toggle on again → must provide api_key_secret again
→ new TwiML app provisioned

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-29 11:32:19 +04:00
f8f0caf443 feat(campaigns): Add variable support to WhatsApp campaigns (#13649)
Fixes
https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates

This PR adds liquid variable support to WhatsApp campaigns, enabling
dynamic per-contact personalization. It supports the same liquid
variables as SMS campaigns ({{contact.name}}, {{contact.email}}, etc.).
Variables are processed per-contact when the campaign executes, allowing
personalized messages at scale.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-28 21:57:30 +04:00
6aeda0ddf6 feat: add Playwright setup and login flow test (#13578)
## Description

Adds Playwright E2E testing infrastructure with project configuration
and a login flow test. This is
Phase 1 of the Playwright E2E suite, kept minimal with only the core
setup and login component.

  Ref: Discussion #13500, PR #13067

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)


## How Has This Been Tested?

- Verified all imports resolve correctly
(`login-flow-ui-validation.spec.ts` only imports `Login` from
  `@components/ui`)
  - Ran login flow test locally against a running Chatwoot instance


## Checklist:

  - [x] My code follows the style guidelines of this project
  - [x] I have performed a self-review of my code
  - [x] I have made corresponding changes to the documentation
  - [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-04-28 18:21:05 +05:30
fed32d5964 fix(perf): force better index for pending_reponses longest first sort (#14291)
## Description

Fix a Postgres planner trap on the "Pending Response: Longest first"
sort that causes the conversation list to hang on busy accounts.

The current `sort_on_waiting_since` generates query with `ORDER BY
waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly
the shape of the single-column `index_conversations_on_waiting_since`
btree, so the planner picks a forward index walk thinking `LIMIT 25`
will stop early. In practice the per-account matches are spread along
the global waiting_since timeline, so the scan reads tens of millions of
rows from other accounts and discards them via the filter before
producing any results which in turn causes the requests to time out and
the conversation list spinner never resolves.

DESC direction and every other sort (`priority`, `created_at`,
`last_activity_at`) are unaffected. They fall through to
`conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is
the right index for this access pattern.

This change leads the ORDER BY with the expression `(waiting_since IS
NULL)`, which no column-only btree can satisfy. The planner falls back
to the same account-scoped index used by every other sort, and sorts in
memory. Same logical NULLS LAST output for both directions; no behavior
change for users.

Fixes CW-6965

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- [x] Existing specs pass
- [x] Added new specs to cover NULL case
- [x] Verify results and order for old and new query in prod
- [x] Tested in prod since staging data was not sufficient

`EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT
25) on a representative production account, before vs after:

| Metric | Before | After |
| --- | --- | --- |
| Execution time | 34,679 ms | 0.71 ms |
| Rows discarded by filter | 36,906,962 | 0 |
| Shared buffer hits | 12,699,924 | 11 |
| Blocks read from disk | 851,226 | 112 |
| I/O read time | 17,785 ms | 0.3 ms |
| Pages dirtied / written | 98 / 31,043 | 0 / 0 |

Verified on two production accounts: identical row IDs in identical
order between the old and new ORDER BY for both ASC and DESC. A
NULL-bucket regression spec was added covering ASC/DESC tail ordering
when some conversations have a null `waiting_since`.

Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and
trivially less I/O and buffer pressure on the cluster while it runs.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-04-28 18:50:29 +07:00
735bc73c96 fix: Preserve single newlines in outgoing email messages (#14138)
# Pull Request Template

## Description

This PR fixes an issue where outgoing Email messages (via API) do not
preserve single line breaks in rendered HTML.

#### Cause

Messages are stored with `\n`, but rendering differs:

* **Other channel** (`markdown-it`, `breaks: true`) → `\n` → `<br>`
* **Email** (CommonMark) without `HARDBREAKS` → `\n` collapsed into
spaces

Result: multi-line messages appear as a single paragraph in Email.

#### Solution

* Added `hardbreaks:` option to `render_message` (default: false)
* Enabled `hardbreaks: true` in `EmailHelper#render_email_html`

This ensures `\n` renders as `<br />` in Email, matching web widget
behavior.


Fixes
https://linear.app/chatwoot/issue/CW-6941/outgoing-email-messages-strip-single-newlines-from-plain-text-content

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

#### Screenshots

**Before**
<img width="604" height="104" alt="image"
src="https://github.com/user-attachments/assets/f9086ffb-a5c7-4688-99aa-97ea5edcccde"
/>


**After**
<img width="604" height="210" alt="image"
src="https://github.com/user-attachments/assets/a8f21c76-bcb8-4058-937a-dd185fb6745c"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-04-28 12:47:03 +04:00
5e79dd699e fix: prevent country defaulting to Zimbabwe in search result contacts (#14098)
## Context

Same root-cause as #7822 / #14078, but in a second file that PR #14078
doesn't touch:

\`app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue\`.

\`countries.json\` entries only have \`id\` (e.g. \`"AF"\`) — there is
no \`code\` field. So:

\`\`\`js
const countriesMap = countries.reduce((acc, country) => {
acc[country.code] = country; // country.code is undefined →
acc[undefined] = country
  acc[country.id] = country;
  return acc;
}, {});
\`\`\`

…overwrites \`acc[undefined]\` on every iteration, leaving whichever
country comes last in the list (Zimbabwe, \`id: "ZW"\`). Later, the
\`countryDetails\` lookup falls back to that value whenever the
contact's \`country\` / \`countryCode\` is missing or unknown, and
Zimbabwe is displayed incorrectly.

## Fix

One-line delete — drop the dead \`acc[country.code] = country\` write.
Lookups by ISO code continue to work via \`country.id\`.

## Scope

Only the search-results card. The Contacts card is already being fixed
in #14078 with the same one-line delete. It's worth patching this
surface too so the same symptom doesn't reappear when the same contact
is accessed via \`Ctrl+K\` search.

## Test plan

- [ ] Reproduce: search for a contact with \`country: null\` /
\`countryCode: null\` — before this patch the flag renders as Zimbabwe;
after, it renders as no country (current expected fallback).
- [ ] Search for a contact with a valid ISO \`countryCode\` (e.g.
\`"IN"\`) — country still resolves correctly.
- [ ] Contacts list page (\`ContactsCard.vue\`, fixed in #14078) — no
regression.

## Follow-up (not in this PR)

Both components keep rebuilding the same \`countriesMap\` per mount. A
small \`shared/constants/countries.js\` export (\`export const byId =
countries.reduce(...)\`, computed once at module load) would save the
per-mount cost and centralise the shape so this bug can't return.

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

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-28 12:08:46 +05:30
05dd31389e fix(whatsapp): Prevent duplicate conversations from concurrent uploads (#14060)
When a WhatsApp contact starts a new conversation by sending multiple
images at once (an album), each image arrives as a separate webhook.
Because no conversation exists yet, the concurrent workers each pass the
"does a conversation exist?" check and each create their own
conversation — producing one conversation per image instead of one
grouped conversation.

This fix serializes webhook processing per `(inbox, contact)` using a
Redis lock at the job level, so only one webhook at a time can create
the initial conversation for a given contact. Concurrent workers retry
with backoff and append to the same conversation once the lock is
released.

## Closes

- Closes #13261

## How to test

1. On a WhatsApp inbox, ensure there is no active (open) conversation
with a specific test contact — resolve or delete any existing one.
2. From a phone, select 6+ images in the WhatsApp gallery and send them
as a single album to the Chatwoot-connected number.
3. Open the Chatwoot dashboard and confirm exactly **one** new
conversation is created, with all images grouped under it.
4. Repeat the test with a mix of attachment types (XMLs, PDFs, images)
sent in rapid succession — still one conversation.

## What changed

- New Redis key `WHATSAPP_MESSAGE_CREATE_LOCK::<inbox_id>::<sender_id>`
in `lib/redis/redis_keys.rb`.
- `Webhooks::WhatsappEventsJob` now inherits from `MutexApplicationJob`
and wraps event processing in `with_lock(key)`, matching the pattern
already used by `FacebookEventsJob`, `InstagramEventsJob`, and
`TiktokEventsJob`.
- Uses `retry_on LockAcquisitionError, wait: 1.second, attempts: 8` so
concurrent webhooks retry until the lock is free instead of poll-waiting
inside the service.
- Sender ID is derived from the webhook payload (contact's `from`, or
`to` for SMB echo events); status-only webhooks bypass the lock.
- Issue 1 from the report (same `source_id` redelivery) was already
handled previously by `Whatsapp::MessageDedupLock` (atomic `SET NX EX`);
no changes needed there.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 10:30:27 +04:00
f7bbd40816 fix(slack): Sync bot interactive responses (#14076)
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.

Closes https://linear.app/chatwoot/issue/PLA-147

### Preview

<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>

### How to test

  1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
  3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:29:03 +04:00
Konstantin VasilevandGitHub 7820739f3a fix: prevent Country from being set to Zimbabwe in the contacts list (#14078)
## Description

This PR resolves an issue that was causing Zimbabwe country being
selected in the Contacts list.
The root cause is objects in `countries.json` file are missing the
`code` property which was causing the countries map to always have the
last country in the list also map to an `undefined` key. In turn, this
was causing an error when contact's `country` was undefined.

Additional thing to keep in mind: I am not sure if there is a case when
contact's `country` property is used or if it can be removed as well,
but in my Chatwoot installation contacts only have a `countryCode`
property, and there is no way to set `country` from the widget sdk.

Fixes #7822

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Simply running the vue application locally targeting my live chatwoot
API.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-28 11:36:40 +05:30
162 changed files with 5336 additions and 2406 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
+3 -3
View File
@@ -11,9 +11,7 @@ class MessageFinder
private
def conversation_messages
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
end
def messages
@@ -50,3 +48,5 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse
end
end
MessageFinder.prepend_mod_with('MessageFinder')
+1 -1
View File
@@ -7,7 +7,7 @@ module EmailHelper
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
end
# Raise a standard error if any email address is invalid
+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" />
@@ -1,68 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class VoiceCallsAPI extends ApiClient {
constructor() {
super('voice_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`);
}
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
// it to Meta via pre_accept_call+accept_call.
accept(callId, { sdpAnswer } = {}) {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`);
}
// Outbound: browser builds the offer first; Rails ships it to Meta and
// creates the Call record. Meta delivers its SDP answer later via the
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
initiate(conversationId, provider, { sdpOffer } = {}) {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
provider,
sdp_offer: sdpOffer,
});
}
// Get the current agent's active call (if any). Used on page load to detect
// a stale active session so we can terminate it cleanly.
active() {
return axios.get(`${this.url}/active`);
}
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
// controller attaches it to the call's voice_call message; the after-create
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
uploadRecording(callId, blob, filename) {
const fd = new FormData();
fd.append('recording', blob, filename);
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
// needs the absolute account-scoped path).
uploadRecordingUrl(callId) {
return `${this.url}/${callId}/upload_recording`;
}
terminateUrl(callId) {
return `${this.url}/${callId}/terminate`;
}
}
export default new VoiceCallsAPI();
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -3,10 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -34,12 +34,11 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
(inboxesList.value || []).filter(isVoiceCallEnabled)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -67,16 +66,15 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.setActiveCall({
id: response.call_id,
callId: response.call_sid,
provider: response.provider || 'twilio',
direction: 'outbound',
status: 'ringing',
conversationId: response.conversation_id,
caller: null,
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -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);
@@ -81,13 +81,9 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const voiceCallEnabled = computed(() => props.voiceEnabled);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox &&
isRegularMessageMode.value &&
!voiceCallEnabled.value
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
);
});
@@ -112,7 +108,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !voiceCallEnabled.value) setSignature();
if (newValue && !props.voiceEnabled) setSignature();
});
},
{ immediate: true }
@@ -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];
@@ -1,10 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -33,65 +30,14 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const router = useRouter();
const { contentAttributes, messageType, attachments } = useMessageContext();
const { call } = useMessageContext();
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
// applies useCamelCase(messages, { deep: true }) before rendering.
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const 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)
);
// Call source and metadata — all camelCase due to deep transform
const callSource = computed(() => data.value?.callSource);
const isVoiceCall = computed(() =>
['whatsapp', 'twilio'].includes(callSource.value)
);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
// Recording and transcript live on the first audio attachment. After the
// call ends, CallRecordingFetchJob creates an Attachment on this message;
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
const audioAttachment = computed(
() => attachments.value?.find(a => a.fileType === 'audio') || null
);
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
const transcript = computed(() => audioAttachment.value?.transcribedText);
const isJoining = ref(false);
const isRejecting = ref(false);
const showTranscript = ref(false);
// Reject is shown only for ringing inbound calls — outbound calls and
// in-progress calls don't need a reject button (initiator hangs up via the
// floating widget).
const showRejectButton = computed(
() =>
isVoiceCall.value &&
!isOutbound.value &&
status.value === VOICE_CALL_STATUS.RINGING
);
const formattedDuration = computed(() => {
const seconds = durationSeconds.value;
if (!seconds || seconds <= 0) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
});
// Show join/accept button logic
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
// DTLS fingerprint), so only ringing calls show the Accept button.
const showJoinButton = computed(
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -105,19 +51,6 @@ const labelKey = computed(() => {
});
const subtextKey = computed(() => {
// acceptedBy on outbound calls is the initiator, not the contact — so keep
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
// should suppress the subtext in favor of the acceptedBy line.
if (
!isOutbound.value &&
acceptedBy.value?.name &&
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
status.value
)
) {
return null;
}
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
@@ -129,50 +62,12 @@ const subtextKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const answeredByText = computed(() => {
if (isOutbound.value) return '';
if (!acceptedBy.value?.name) return '';
return acceptedBy.value.name;
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const handleJoinCall = async () => {
if (isJoining.value || !isVoiceCall.value) return;
isJoining.value = true;
try {
const result = await acceptVoiceCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: result.call.conversationId },
});
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Accept from bubble failed:', err);
} finally {
isJoining.value = false;
}
};
const handleRejectCall = async () => {
if (isRejecting.value || !callId.value) return;
isRejecting.value = true;
try {
await VoiceCallsAPI.reject(callId.value);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Reject from bubble failed:', err);
} finally {
isRejecting.value = false;
}
};
</script>
<template>
@@ -193,90 +88,14 @@ const handleRejectCall = async () => {
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span v-if="answeredByText" class="text-xs text-n-slate-11">
{{
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
name: answeredByText,
})
}}
</span>
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
class="text-xs text-n-slate-10"
>
{{ formattedDuration }}
</span>
</div>
<button
v-if="showRejectButton"
:disabled="isRejecting"
:title="$t('WHATSAPP_CALL.REJECT')"
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isRejecting }"
@click="handleRejectCall"
>
<i
v-if="isRejecting"
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
/>
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
</button>
<button
v-if="showJoinButton"
:disabled="isJoining"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isJoining }"
@click="handleJoinCall"
>
<i
v-if="isJoining"
class="i-ph-circle-notch-bold text-sm animate-spin"
/>
<i v-else class="i-ph-phone-bold text-sm" />
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
</button>
</div>
<div
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-2"
>
<audio controls class="w-full h-8" :src="recordingUrl">
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-3"
>
<button
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
@click="showTranscript = !showTranscript"
>
<i
class="text-sm"
:class="
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
"
/>
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
</button>
<p
v-if="showTranscript"
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
>
{{ transcript }}
</p>
</div>
</div>
</BaseBubble>
@@ -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';
@@ -1,244 +0,0 @@
<script setup>
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useVoiceCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('voice_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('voice_call:permission_granted', onPermissionGranted);
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{
call.provider === 'twilio'
? 'Incoming voice call'
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
<template v-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -51,6 +51,7 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -66,6 +67,7 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -744,6 +753,9 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -362,19 +364,33 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
onKeydown(view, event) {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -13,14 +13,6 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import {
prepareOutboundOffer,
cleanupInboundWebRTC,
} from 'dashboard/composables/useVoiceCallSession';
const props = defineProps({
chat: {
@@ -38,10 +30,7 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
useInbox();
const voiceCallsStore = useVoiceCallsStore();
const isInitiatingCall = ref(false);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -102,99 +91,6 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
// Returns the provider to use for an outbound call, based on the inbox
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
// use :twilio. Returns null if outbound calling isn't supported on this
// channel.
const callProvider = computed(() => {
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
return 'whatsapp';
}
if (voiceCallEnabled.value) {
return 'twilio';
}
return null;
});
const canInitiateVoiceCall = computed(() => {
if (!callProvider.value) return false;
if (voiceCallsStore.hasVoiceCall) return false;
return true;
});
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
// the browser builds the SDP offer locally before any network call, so Meta
// gets the agent's real fingerprint and the eventual answer applies cleanly
// in the cable handler. Twilio still POSTs first and waits for the
// synchronous agent_offer in the response (its OutboundCallBuilder produces
// the SDP server-side). On 138006 (no call permission) we tear down the local
// PC immediately so the mic light goes off while the contact's phone shows
// the consent prompt.
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
return;
isInitiatingCall.value = true;
const isWhatsApp = callProvider.value === 'whatsapp';
try {
let sdpOffer = null;
if (isWhatsApp) {
sdpOffer = await prepareOutboundOffer({});
}
const response = await VoiceCallsAPI.initiate(
currentChat.value.id,
callProvider.value,
isWhatsApp ? { sdpOffer } : {}
);
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
// Tear down the local PC; we'll rebuild from scratch on retry.
cleanupInboundWebRTC();
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
const callData = response.data || {};
voiceCallsStore.setActiveCall({
id: callData.id,
callId: callData.call_id,
provider: callData.provider || callProvider.value,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
cleanupInboundWebRTC();
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
<template>
@@ -256,19 +152,6 @@ const initiateWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="canInitiateVoiceCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -1,36 +0,0 @@
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { useAlert } from 'dashboard/composables';
/**
* Detects a stale active call on page load and cleans it up. Direct
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
* Best we can do is terminate the stranded Call so the agent isn't shown
* "in a call" forever.
*/
export function useCallReconnection() {
const callsStore = useVoiceCallsStore();
const { t } = useI18n();
onMounted(async () => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
try {
const { data } = await VoiceCallsAPI.active();
if (!data?.call && !data?.id) return;
const activeCallData = data.call || data;
try {
await VoiceCallsAPI.terminate(activeCallData.id);
} catch {
// best-effort — Rails-side cleanup
}
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
} catch {
callsStore.clearActiveCall();
}
});
}
@@ -1,654 +0,0 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Timer from 'dashboard/helper/Timer';
import { emitter } from 'shared/helpers/mitt';
// ─────────────────────────────────────────────────────────────────────────────
// Module-level WebRTC + recorder state. One active peer per browser tab.
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
// disambiguates so beforeunload doesn't warn during a clean hangup.
// ─────────────────────────────────────────────────────────────────────────────
let inboundPc = null;
let inboundStream = null;
let inboundAudio = null;
let audioContext = null;
let mediaRecorder = null;
let recordingMime = null;
let recordingChunks = [];
let recordingFilename = null;
let intentionallyClosing = false;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
function pickRecorderMime() {
if (typeof MediaRecorder === 'undefined') return null;
return (
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
);
}
function teardownRecorder() {
mediaRecorder = null;
recordingChunks = [];
recordingMime = null;
recordingFilename = null;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
}
function extensionFromMime(mime) {
if (!mime) return 'webm';
if (mime.startsWith('audio/webm')) return 'webm';
if (mime.startsWith('audio/ogg')) return 'ogg';
return 'webm';
}
// Mix the agent's mic and Meta's remote audio into a single stream so the
// resulting Blob is a real conversation rather than two interleaved channels.
// Web Audio handles the mix; we never apply gain (raw levels matter for
// Whisper's speech-detection floor).
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
if (mediaRecorder || !localStream || !remoteStream) return;
const mime = pickRecorderMime();
if (!mime) {
// eslint-disable-next-line no-console
console.warn(
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
);
return;
}
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000,
});
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const localSource = audioContext.createMediaStreamSource(localStream);
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
const mixDest = audioContext.createMediaStreamDestination();
localSource.connect(mixDest);
remoteSource.connect(mixDest);
recordingMime = mime;
recordingChunks = [];
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size) recordingChunks.push(event.data);
};
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
// beacon to capture most of the call if the user closes the tab.
mediaRecorder.start(5000);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recorder setup failed:', err);
teardownRecorder();
}
}
// stop() returns nothing useful; the final dataavailable fires on the next
// task and onstop fires after that. Wait for onstop, then assemble the blob.
async function stopRecorderAndGetBlob() {
if (!mediaRecorder) return null;
const mr = mediaRecorder;
if (mr.state === 'inactive') {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
teardownRecorder();
return { blob, filename: recordingFilename };
}
return new Promise(resolve => {
const cleanup = () => {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const filename = recordingFilename;
teardownRecorder();
resolve({ blob, filename });
};
mr.onstop = cleanup;
try {
mr.stop();
} catch {
cleanup();
}
});
}
function cleanupInboundWebRTC() {
// Stop recorder first so the source nodes still see live tracks during
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
teardownRecorder();
if (inboundStream) {
inboundStream.getTracks().forEach(track => track.stop());
inboundStream = null;
}
if (inboundPc) {
inboundPc.close();
inboundPc = null;
}
if (inboundAudio) {
inboundAudio.srcObject = null;
if (inboundAudio.parentNode) {
inboundAudio.parentNode.removeChild(inboundAudio);
}
inboundAudio = null;
}
}
function waitForIceGatheringComplete(pc) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
// eslint-disable-next-line no-console
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
}
function attachRemoteTrackHandler(pc, callId, providerCallId) {
pc.ontrack = event => {
let remoteStream = event.streams && event.streams[0];
if (!remoteStream) {
if (!event.track) return;
remoteStream = new MediaStream([event.track]);
}
if (!inboundAudio) {
const audio = document.createElement('audio');
audio.autoplay = true;
document.body.appendChild(audio);
inboundAudio = audio;
}
inboundAudio.srcObject = remoteStream;
inboundAudio.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[Voice Call] audio.play() rejected:', err);
});
if (inboundStream) {
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
}
};
}
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
// cable event, opens its mic, builds the answer, and ships it back so Rails
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
async function prepareInboundAnswer({
callId,
providerCallId,
sdpOffer,
iceServers,
}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Outbound: browser builds the offer locally, before any network call. Rails
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
// later via the voice_call.outbound_connected cable event.
async function prepareOutboundOffer({
callId,
providerCallId,
iceServers,
} = {}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
// from the actionCable handler when voice_call.outbound_connected lands.
async function applyOutboundAnswer(sdpAnswer) {
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
if (inboundPc.signalingState !== 'have-local-offer') {
// Already applied — duplicate cable delivery. No-op rather than detonate.
return;
}
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Some browsers fire ontrack synchronously inside setRemoteDescription;
// others delay it. If we already have a remote stream, kick the recorder.
if (inboundAudio?.srcObject && inboundStream) {
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
}
}
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
// Standalone for the message bubble's Accept button. Branches on whether the
// store has an SDP offer (WhatsApp direct mode = always yes).
export async function acceptVoiceCallById(callId) {
const callsStore = useVoiceCallsStore();
if (callsStore.hasActiveCall) {
return { success: false, error: 'active_call_exists' };
}
let call = callsStore.incomingCalls.find(
c => c.id === callId || c.callId === String(callId)
);
let sdpOffer = call?.sdpOffer;
let iceServers = call?.iceServers;
let providerCallId = call?.callId;
if (!sdpOffer) {
const { data } = await VoiceCallsAPI.show(callId);
if (data.status !== 'ringing') {
return { success: false, error: 'not_ringing' };
}
sdpOffer = data.sdp_offer;
iceServers = data.ice_servers;
providerCallId = data.call_id;
call = {
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer,
iceServers,
};
callsStore.addIncomingCall(call);
}
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
// network call so the cable handler doesn't drop a frame on a null guard.
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
return { success: true, call: activeCallData };
} catch (err) {
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall?.();
throw err;
}
}
// Best-effort upload. Errors are swallowed because the call has already
// completed by the time we get here; surfacing failures isn't actionable.
async function uploadRecordingBlob(callId) {
if (!mediaRecorder && recordingChunks.length === 0) return;
try {
const result = await stopRecorderAndGetBlob();
if (!result?.blob || result.blob.size === 0) return;
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recording upload failed:', err);
}
}
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
export function useVoiceCallSession() {
const { t } = useI18n();
const callsStore = useVoiceCallsStore();
const getters = useStoreGetters();
const accountId = computed(() => getters.getCurrentAccountId.value);
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const isOutboundRinging = computed(
() =>
activeCall.value?.direction === 'outbound' &&
activeCall.value?.status === 'ringing'
);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
// the only one that ships audio to the server. uploadRecordingBlob
// gathers the blob via the recorder's onstop listener and then frees the
// recorder — only AFTER that completes do we close the PC and stop tracks.
callsStore.registerCleanupCallback(async callId => {
const idForUpload = callId || activeCall.value?.id;
durationTimer.stop();
callDuration.value = 0;
try {
if (idForUpload) await uploadRecordingBlob(idForUpload);
} finally {
cleanupInboundWebRTC();
}
});
// Warn the user if they try to navigate away mid-call. The actual cleanup
// happens in the pagehide handler — beforeunload is purely the warning.
const handleBeforeUnload = e => {
if (!callsStore.activeCall || intentionallyClosing) return undefined;
e.preventDefault();
e.returnValue = '';
// Some browsers (older Safari/Firefox) only honour a returned string.
// eslint-disable-next-line consistent-return
return '';
};
// Fires after the user confirms close (or on tab switch). We use fetch
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
// authenticates via devise-token-auth headers (`access-token`, `client`,
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
// fetch+keepalive sends both cookies AND custom headers and the request
// survives page unload (capped at ~64KB body, fine for terminate).
const handlePageHide = () => {
const call = callsStore.activeCall;
if (!call || intentionallyClosing) return;
// Stop the recorder synchronously; final dataavailable already fired via
// the 5s timeslice, so chunks[] is approximately current.
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
const sessionHeaders = (() => {
try {
return JSON.parse(
decodeURIComponent(
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
[])[2] || '%7B%7D'
)
);
} catch {
return {};
}
})();
const authHeaders = {
'access-token': sessionHeaders['access-token'] || '',
client: sessionHeaders.client || '',
uid: sessionHeaders.uid || '',
expiry: sessionHeaders.expiry || '',
'token-type': sessionHeaders['token-type'] || 'Bearer',
};
if (recordingChunks.length > 0 && accountId.value) {
try {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const fd = new FormData();
fd.append(
'recording',
blob,
recordingFilename || `call_${call.id}.webm`
);
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
{
method: 'POST',
body: fd,
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
if (accountId.value) {
try {
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
{
method: 'POST',
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
cleanupInboundWebRTC();
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
// Start timer when an outbound call transitions to connected.
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
// Floating-widget Accept button. Same WhatsApp-direct flow as
// acceptVoiceCallById — see comments there.
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
const sdpOffer = call.sdpOffer;
const iceServers = call.iceServers;
if (!sdpOffer) {
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
isAccepting.value = false;
return;
}
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId: call.callId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
durationTimer.start();
emitter.emit('voice_call:agent_webrtc_connected');
} catch (err) {
callError.value =
err?.name === 'NotAllowedError'
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[Voice Call] acceptCall error:', err);
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await VoiceCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
intentionallyClosing = true;
try {
await uploadRecordingBlob(call.id);
} catch {
// Best effort
}
try {
await VoiceCallsAPI.terminate(call.id);
} catch {
// Best effort
} finally {
cleanupInboundWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
intentionallyClosing = false;
}
};
const toggleMute = () => {
if (!inboundStream) return;
const audioTrack = inboundStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
const startDurationTimer = () => {
durationTimer.start();
};
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
};
}
@@ -1,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',
+5 -70
View File
@@ -4,8 +4,6 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
const { isImpersonating } = useImpersonation();
@@ -35,12 +33,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.ended': this.onVoiceCallEnded,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
};
}
@@ -201,75 +195,16 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
// the bubble's Accept button can build the WebRTC answer locally
// without re-fetching from /show.
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
onVoiceCallAccepted = data => {
const voiceCallsStore = useVoiceCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
if (data.accepted_by_agent_id !== currentUserId) {
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.handleCallEnded(data.call_id);
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
// contact picks up. Apply it to the existing local RTCPeerConnection so
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
// by prepareOutboundOffer when the agent clicked the call button.
const voiceCallsStore = useVoiceCallsStore();
const activeCall = voiceCallsStore.activeCall;
if (!activeCall || activeCall.callId !== data.call_id) return;
if (!data.sdp_answer) return;
applyOutboundAnswer(data.sdp_answer)
.then(() => {
voiceCallsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallPermissionGranted = data => {
emitter.emit('voice_call:permission_granted', {
contactName: data.contact_name,
});
};
}
export default {
@@ -2,6 +2,7 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
}
};
/**
* Collapse the current selection to a cursor near its head. Used to override
* the default Escape -> selectParentNode behavior which would otherwise keep
* the text highlight visible.
*
* @param {EditorView} view - The ProseMirror EditorView
*/
export const collapseSelection = view => {
const { tr, selection } = view.state;
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
+1
View File
@@ -21,6 +21,7 @@ export const VOICE_CALL_PROVIDERS = {
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
@@ -16,6 +16,7 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -83,11 +83,7 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"ANSWERED_BY": "Answered by {name}",
"ACCEPT_CALL": "Accept",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
"TRANSCRIPT": "Transcript"
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -645,7 +645,7 @@
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
@@ -36,7 +36,6 @@ import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappCall from './whatsappCall.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
@@ -82,7 +81,6 @@ export default {
...sla,
...snooze,
...teamsSettings,
...whatsappCall,
...whatsappTemplates,
...contentTemplates,
...mfa,
@@ -1,20 +0,0 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"RINGING": "Ringing…",
"CALL_FAILED": "Call failed. Please try again.",
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
}
}
@@ -49,7 +49,6 @@ const navigateTo = computed(() => {
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
@@ -20,17 +20,11 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
export default {
components: {
@@ -42,7 +36,6 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -51,13 +44,6 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const voiceCallsStore = useVoiceCallsStore();
// Detect a stale active WhatsApp call left over from a previous tab /
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
// path because Meta caches our DTLS fingerprint; the only safe action
// is to clean up server-side state and tell the agent the call dropped.
useCallReconnection();
return {
uiSettings,
@@ -65,15 +51,8 @@ export default {
accountId,
upgradePageRef,
windowWidth,
// Twilio voice still flows through useCallSession + the calls store.
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
// voiceCalls store. Both widgets can mount simultaneously; only one
// ever has state at a time because they listen to disjoint cable
// events.
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
};
},
data() {
@@ -184,7 +163,6 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -132,8 +132,10 @@ const headers = computed(() => [
</template>
</BaseTable>
<div class="flex items-center justify-between mt-4">
<p class="text-body-main text-n-slate-11">
<div
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
>
<p class="text-body-main text-n-slate-11 mb-0">
{{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js';
/**
* Gets a value from a conversation based on the attribute key
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
// For array values like labels, match if any filter value is present.
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
return filterValue.some(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
// a conversation matches if ANY of the filter labels is on it.
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
const conversation = { labels: ['support'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
const conversation = { labels: ['new'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [
@@ -1,65 +0,0 @@
import { defineStore } from 'pinia';
export const useVoiceCallsStore = defineStore('voiceCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action (per account)
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasVoiceCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
// Snapshot the DB id before nulling activeCall so the composable can
// POST upload_recording with the right path even after we clear state.
const dbCallId = this.activeCall?.id;
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
this.activeCall = null;
}
},
},
});
@@ -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 = {
+17 -6
View File
@@ -23,13 +23,24 @@ class HookJob < MutexApplicationJob
private
def process_slack_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
message = event_data[:message]
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
case event_name
when 'message.created'
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
when 'message.updated'
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
# Skip other content types to avoid unnecessary job enqueues on every message update.
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
# while submitted_values is already present on the message.
return unless event_data[:previous_changes]&.key?('content_attributes')
::UpdateSlackMessageJob.perform_later(message, hook)
end
end
+11
View File
@@ -0,0 +1,11 @@
class UpdateSlackMessageJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
end
end
end
+29 -1
View File
@@ -1,5 +1,9 @@
class Webhooks::WhatsappEventsJob < ApplicationJob
class Webhooks::WhatsappEventsJob < MutexApplicationJob
queue_as :low
# Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise
# a webhook that arrives just after the lock is acquired can exhaust retries before the
# holder finishes and silently drop its message.
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
@@ -9,6 +13,20 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return
end
sender_id = contact_sender_id(params)
return process_events(channel, params) if sender_id.blank?
# Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact)
# so the first webhook creates the conversation and the rest append to it.
# 30s TTL covers the attachment download + transaction — the default 1s expires
# mid-processing and lets a concurrent webhook re-acquire before the first commit.
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id)
with_lock(key, 30.seconds) do
process_events(channel, params)
end
end
def process_events(channel, params)
if message_echo_event?(params)
handle_message_echo(channel, params)
else
@@ -69,6 +87,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
private
# Echo payloads reverse the fields — `from` is the business number and `to` is the contact.
# Returns nil for status-only webhooks so they bypass the lock.
def contact_sender_id(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
message = (value[:messages] || value[:message_echoes])&.first
return if message.blank?
message[:to] || message[:from]
end
def channel_is_inactive?(channel)
return true if channel.blank?
return true if channel.reauthorization_required?
+2 -2
View File
@@ -43,7 +43,7 @@ class HookListener < BaseListener
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message)
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
end
end
@@ -59,7 +59,7 @@ class HookListener < BaseListener
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created'],
'slack' => ['message.created', 'message.updated'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
+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,
+2 -2
View File
@@ -61,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -71,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
end
end
private
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }
+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)
+1 -1
View File
@@ -19,7 +19,7 @@ module SortHandler
end
def sort_on_waiting_since(sort_direction = :asc)
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
order(generate_sql_query("(waiting_since IS NULL), waiting_since #{sort_direction.to_s.upcase}, created_at ASC"))
end
def last_messaged_conversations
-5
View File
@@ -152,14 +152,9 @@ class Message < ApplicationRecord
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:call] = call.push_event_data if voice_call? && respond_to?(:call) && call.present?
merge_sender_attributes(data)
end
def voice_call?
content_type == 'voice_call'
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
+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
+1 -3
View File
@@ -46,9 +46,7 @@ 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
# voice_call bubbles are in-app system events for the call lifecycle —
# dispatching them to the channel would deliver "WhatsApp Call" as a
# text message to the contact every time the agent placed a call.
# voice_call bubbles are call status indicators, not deliverable messages
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
+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)
@@ -0,0 +1,66 @@
class Whatsapp::LiquidTemplateProcessorService
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
module JsonEscapeFilter
def json_escape(input)
input.to_s.to_json[1..-2]
end
end
pattr_initialize [:campaign!, :contact!]
def process_template_params(template_params)
return template_params if template_params.blank?
template_params_copy = template_params.deep_dup
processed_params = template_params_copy['processed_params']
return template_params_copy if processed_params.blank?
rendered_params = render_liquid(processed_params)
return nil if blank_render?(processed_params, rendered_params)
template_params_copy.merge('processed_params' => rendered_params)
end
private
def render_liquid(processed_params)
raw = processed_params.to_json
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
JSON.parse(rendered)
rescue Liquid::Error, JSON::ParserError
processed_params
end
def drops
{
'contact' => ContactDrop.new(contact),
'agent' => UserDrop.new(campaign.sender),
'inbox' => InboxDrop.new(campaign.inbox),
'account' => AccountDrop.new(campaign.account)
}
end
def blank_render?(original, rendered)
case original
when Hash then blank_render_in_hash?(original, rendered)
when Array then blank_render_in_array?(original, rendered)
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
else false
end
end
def blank_render_in_hash?(original, rendered)
return false unless rendered.is_a?(Hash)
original.any? { |key, value| blank_render?(value, rendered[key]) }
end
def blank_render_in_array?(original, rendered)
return false unless rendered.is_a?(Array)
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
end
end
@@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService
return
end
send_whatsapp_template_message(to: contact.phone_number)
processed_template_params = process_liquid_template_params(contact)
return if processed_template_params.nil?
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
end
def process_audience(audience_labels)
@@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
def process_liquid_template_params(contact)
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
processed_template_params
rescue StandardError => e
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
nil
end
def send_whatsapp_template_message(to:, template_params:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
template_params: template_params
)
name, namespace, lang_code, processed_parameters = processor.call
@@ -1,6 +1,4 @@
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
prepend Whatsapp::Providers::WhatsappCloudCallMethods if defined?(Whatsapp::Providers::WhatsappCloudCallMethods)
def send_message(phone_number, message)
@message = message
@@ -207,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')
+3 -3
View File
@@ -130,9 +130,6 @@ if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
# `calling_enabled` is needed by every agent (not just admins) so the
# ConversationHeader can decide whether to render the outbound call button.
json.calling_enabled resource.channel.try(:provider_config)&.fetch('calling_enabled', false) || false
end
## Voice attributes for TwilioSms
@@ -145,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?)
@@ -13,6 +13,4 @@ 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?
if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
json.set! :call, message.call.push_event_data
end
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),
+3 -3
View File
@@ -191,10 +191,10 @@
- name: assignment_v2
display_name: Assignment V2
enabled: true
- name: twilio_content_templates
display_name: Twilio Content Templates
- name: captain_document_auto_sync
display_name: Captain Document Auto Sync
enabled: false
deprecated: true
premium: true
- name: advanced_search
display_name: Advanced Search
enabled: false
+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
]
+5
View File
@@ -40,6 +40,11 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Merge enterprise-only cron entries when running an enterprise build.
# Mirrors the conditional-load pattern already used for enterprise initializers.
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
+14
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:
@@ -379,6 +390,9 @@ en:
limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB'
sync_not_supported_for_pdf: 'Sync is not supported for PDF documents'
sync_only_available_documents: 'Sync is only available for processed documents'
sync_already_in_progress: 'Document sync is already in progress'
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+19 -14
View File
@@ -77,7 +77,9 @@ Rails.application.routes.draw do
resources :custom_tools do
post :test, on: :collection
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :documents, only: [:index, :show, :create, :destroy] do
post :sync, on: :member
end
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
@@ -213,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
@@ -303,19 +320,6 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
resources :voice_calls, only: [:show] do
collection do
get :active
post :initiate
end
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -664,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
@@ -0,0 +1,22 @@
class RepurposeTwilioContentTemplatesFlagForCaptainDocumentAutoSync < ActiveRecord::Migration[7.1]
def up
# The twilio_content_templates flag (deprecated) has been renamed to captain_document_auto_sync.
# Disable it on any accounts that had twilio_content_templates enabled so the repurposed
# flag starts in its intended default-off state.
Account.feature_captain_document_auto_sync.find_each(batch_size: 100) do |account|
account.disable_features(:captain_document_auto_sync)
account.save!(validate: false)
end
# Remove the stale twilio_content_templates entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
# Leaving it would cause NoMethodError in enable_default_features when
# creating new accounts (feature_twilio_content_templates= no longer exists).
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |f| f['name'] == 'twilio_content_templates' }
config.save!
GlobalConfig.clear_cache
end
end
@@ -0,0 +1,18 @@
class BackfillCaptainDocumentSyncMetadata < ActiveRecord::Migration[7.0]
def up
return unless ChatwootApp.enterprise?
# rubocop:disable Rails/SkipsModelValidations
Captain::Document
.syncable
.where(status: :available, sync_status: nil, last_synced_at: nil)
.in_batches(of: 1000) do |batch|
batch.update_all('sync_status = 1, last_synced_at = updated_at')
end
# rubocop:enable Rails/SkipsModelValidations
end
def down
# No-op. This is a one-time baseline for existing available web documents.
end
end
+9 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1087,6 +1087,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) 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
@@ -7,7 +7,10 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
@responses = process_bulk_action
result = process_bulk_action
return if performed?
@responses = result
end
private
@@ -15,13 +18,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def process_bulk_action
@@ -48,13 +51,38 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end
def handle_documents
return [] unless params[:fields][:status] == 'delete'
case params[:fields][:status]
when 'delete'
delete_documents
when 'sync'
sync_documents
else
[]
end
end
def delete_documents
documents = Current.account.captain_documents.where(id: params[:ids])
return [] unless documents.exists?
return render json: { count: 0 } unless documents.exists?
documents.destroy_all
[]
destroyed_documents = documents.destroy_all
render json: { count: destroyed_documents.size }
end
def sync_documents
synced_document_ids = []
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable?
next unless document.available?
next if document.sync_in_progress?
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
synced_document_ids << document.id
end
render json: { ids: synced_document_ids, count: synced_document_ids.size }
end
def permitted_params
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_document, only: [:show, :destroy, :sync]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
@@ -29,6 +29,16 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
def destroy
@document.destroy
head :no_content
@@ -1,183 +0,0 @@
class Api::V1::Accounts::VoiceCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
def show
render json: call_payload(@call)
end
def accept
call = Voice::CallService.new(
call: @call,
agent: current_user,
sdp_answer: params[:sdp_answer]
).accept
render json: call_payload(call)
rescue Voice::CallErrors::NotRinging, Voice::CallErrors::AlreadyAccepted, Voice::CallErrors::CallFailed => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[VOICE CALL] accept failed: #{e.class} #{e.message}"
render json: { error: 'Failed to accept call' }, status: :internal_server_error
end
def reject
call = Voice::CallService.new(call: @call, agent: current_user).reject
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] reject failed: #{e.class} #{e.message}"
render json: { error: 'Failed to reject call' }, status: :internal_server_error
end
def terminate
call = Voice::CallService.new(call: @call, agent: current_user).terminate
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] terminate failed: #{e.class} #{e.message}"
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
end
# Browser-supplied recording, captured via MediaRecorder during the call.
# Idempotent: subsequent uploads after the first audio attachment exists
# silently no-op so the hangup-vs-pagehide race can't double-attach.
def upload_recording
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
return render json: { id: @call.id, status: 'no_message' }, status: :unprocessable_entity if @call.message.blank?
return render json: { id: @call.id, status: 'already_uploaded' } if @call.message.attachments.exists?(file_type: :audio)
attach_recording!
render json: { id: @call.id, status: 'uploaded' }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] upload_recording failed: #{e.class} #{e.message}"
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
end
def active
call = current_account.calls.active_for_agent(current_user.id).last
if call
elapsed = call.started_at ? (Time.current - call.started_at).to_i : 0
render json: {
id: call.id,
call_id: call.provider_call_id,
provider: call.provider,
conversation_id: call.conversation_id,
status: call.status,
elapsed_seconds: elapsed
}
else
render json: { call: nil }
end
end
def initiate
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
authorize conversation, :show?
initiate_whatsapp(conversation)
rescue Voice::CallErrors::NoCallPermission
handle_no_call_permission(conversation)
rescue ActiveRecord::RecordNotFound
render json: { error: 'Conversation not found' }, status: :not_found
rescue StandardError => e
Rails.logger.error "[VOICE CALL] initiate failed: #{e.class} #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def initiate_whatsapp(conversation)
error = validate_whatsapp_calling(conversation)
return render json: { error: error }, status: :unprocessable_entity if error
return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if params[:sdp_offer].blank?
contact_phone = conversation.contact&.phone_number
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
call = create_whatsapp_outbound_call(conversation, contact_phone, params[:sdp_offer])
message = Voice::CallMessageBuilder.create!(conversation: conversation, call: call, user: current_user)
call.update!(message_id: message.id)
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id, provider: 'whatsapp' }
end
# Browser → Rails → Meta. The browser already opened its mic, built an
# RTCPeerConnection, generated the offer, and waited for ICE gathering.
# We just hand that offer to Meta. Meta returns the call_id immediately
# (no SDP yet); the contact's phone rings; on pickup the connect webhook
# delivers Meta's SDP answer and we relay it back via ActionCable.
def create_whatsapp_outbound_call(conversation, contact_phone, sdp_offer)
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), 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' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
end
def validate_whatsapp_calling(conversation)
channel = conversation.inbox.channel
return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud'
return 'Calling is not enabled for this inbox' unless channel.provider_config['calling_enabled']
nil
end
def handle_no_call_permission(conversation)
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago
contact_phone = conversation.contact.phone_number.delete('+')
result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result
attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)
conversation.update!(additional_attributes: attrs)
render json: { status: 'permission_requested' }
end
def call_payload(call)
{
id: call.id,
call_id: call.provider_call_id,
provider: call.provider,
status: call.status,
direction: call.direction_label,
conversation_id: call.conversation_id,
inbox_id: call.inbox_id,
message_id: call.message_id,
accepted_by_agent_id: call.accepted_by_agent_id,
elapsed_seconds: call.started_at ? (Time.current - call.started_at).to_i : 0,
sdp_offer: call.meta&.dig('sdp_offer'),
ice_servers: call.meta&.dig('ice_servers') || Call.default_ice_servers,
caller: caller_info(call)
}
end
def caller_info(call)
contact = call.conversation&.contact
return {} unless contact
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
end
def set_call
@call = current_account.calls.find(params[:id])
authorize @call.conversation, :show?
rescue ActiveRecord::RecordNotFound
render json: { error: 'Call not found' }, status: :not_found
end
def attach_recording!
@call.message.attachments.create!(
account_id: @call.account_id,
file_type: :audio,
file: params[:recording]
)
end
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
@@ -30,12 +30,14 @@ class Twilio::VoiceController < ApplicationController
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
call = find_call_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(
call: call,
@@ -82,7 +84,6 @@ class Twilio::VoiceController < ApplicationController
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
@@ -106,7 +107,8 @@ class Twilio::VoiceController < ApplicationController
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
Voice::CallSessionSyncService.new(call: call, parent_call_sid: parent_sid).perform
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
call
end
def inbox_calls
@@ -134,7 +136,7 @@ class Twilio::VoiceController < ApplicationController
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: Voice::Conference::Name.for(call))
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
@@ -149,7 +151,7 @@ class Twilio::VoiceController < ApplicationController
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_call_for_conference!(friendly_name:, call_sid:)
def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
@@ -157,9 +159,9 @@ class Twilio::VoiceController < ApplicationController
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: e164)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{e164}" unless channel.voice_enabled?
phone_number = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
@inbox = channel.inbox
end
@@ -0,0 +1,5 @@
module Enterprise::MessageFinder
def conversation_messages
super.includes(call: [:contact, { inbox: :channel }])
end
end
@@ -1,6 +1,9 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
# A single page fetch + fingerprint compare should complete in seconds.
# 10 minutes is generous headroom — if still "syncing" after that, the worker likely died mid-run.
# Shared with ScheduleSyncsJob so stale locks are re-enqueued at the same threshold.
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
@@ -78,6 +81,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
@@ -0,0 +1,65 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
PER_ACCOUNT_HOURLY_CAP = 50
GLOBAL_HOURLY_CAP = 1000
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval
next unless interval
stats[:accounts_scheduled] += 1
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
end
log_scheduler_summary(stats)
end
private
def enqueue_due_documents(account, interval)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
enqueued_count = 0
account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)',
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
next unless document.syncable?
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1
enqueued_count += 1
end
enqueued_count
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
global_cap_hit: @remaining_global_capacity <= 0,
remaining_global_capacity: @remaining_global_capacity
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
end
@@ -14,7 +14,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
status: :available,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
@@ -1,6 +1,10 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
class PermanentCrawlError < StandardError; end
queue_as :low
discard_on PermanentCrawlError
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
@@ -11,23 +15,71 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success?
persist_document!(document, normalized_link, crawler)
rescue PermanentCrawlError
raise
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
private
def handle_failed_fetch!(document, status_code, page_link)
error_code = http_error_code(status_code)
mark_failed!(document, error_code) if document.persisted?
error_message = "Failed to fetch page: #{page_link}"
raise PermanentCrawlError, error_message if permanent_failure?(error_code)
raise error_message
end
def persist_document!(document, normalized_link, crawler)
document.update!(
external_link: normalized_link,
name: (crawler.page_title || '')[0..254],
content: (crawler.body_markdown || '')[0..14_999],
status: :available,
**synced_attributes
)
end
def synced_attributes
{
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
}
end
def mark_failed!(document, error_code)
document.update!(
status: :available,
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
def permanent_failure?(error_code)
Captain::Documents::SyncService::PERMANENT_ERROR_CODES.include?(error_code)
end
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
@@ -1,41 +1,41 @@
module Enterprise::Webhooks::WhatsappEventsJob
def handle_message_events(channel, params)
if call_event?(params)
handle_call_events(channel, params)
return
end
if call_permission_reply?(params)
handle_call_permission_reply(channel, params)
return
end
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)
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply'
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)
Whatsapp::IncomingCallService.new(
inbox: channel.inbox,
params: extract_call_params(params)
).perform
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
def extract_call_params(params)
params.dig(:entry, 0, :changes, 0, :value) || {}
end
end
+15 -24
View File
@@ -18,29 +18,27 @@
# contact_id :bigint not null
# conversation_id :bigint not null
# inbox_id :bigint not null
# media_session_id :string
# message_id :bigint
# provider_call_id :string not null
#
# Indexes
#
# index_calls_on_accepted_by_agent_id_and_status (accepted_by_agent_id,status)
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
# index_calls_on_media_session_id (media_session_id) UNIQUE
# index_calls_on_message_id (message_id)
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
# index_calls_on_message_id (message_id)
# 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
META_ACCESSORS = %i[conference_sid recording_sid parent_call_sid initiated_at ended_at].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 }
@@ -59,33 +57,26 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :active_for_agent, ->(agent_id) { active.where(accepted_by_agent_id: agent_id) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
META_ACCESSORS.each do |key|
define_method(key) { (meta || {})[key.to_s] }
define_method("#{key}=") { |value| self.meta = (meta || {}).merge(key.to_s => value) }
end
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its
# public srflx candidate. Configurable via VOICE_CALL_STUN_URLS (comma-
# separated). TURN can be added by appending turn:user@host?credential=...
# entries to the same env var.
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', 'stun:stun.l.google.com:19302').split(',').map(&:strip).reject(&:blank?)
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
[{ urls: urls }]
end
def display_direction
def direction_label
DISPLAY_DIRECTION[direction]
end
alias direction_label display_direction
def ringing?
status == 'ringing'
end
+16 -29
View File
@@ -26,12 +26,14 @@
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
SYNC_STALE_TIMEOUT = 2.hours
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
@@ -59,6 +61,7 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -75,36 +78,8 @@ class Captain::Document < ApplicationRecord
pdf_file.blob.byte_size if pdf_file.attached?
end
def content_fingerprint
metadata&.dig('content_fingerprint')
end
def content_fingerprint=(value)
self.metadata = (metadata || {}).merge('content_fingerprint' => value)
end
def last_sync_error_code
metadata&.dig('last_sync_error_code')
end
def last_sync_error_code=(value)
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
update!(openai_file_id: file_id)
end
def display_url
@@ -121,6 +96,18 @@ class Captain::Document < ApplicationRecord
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
def syncable?
!pdf_document?
end
def sync_stale?
sync_syncing? && (last_sync_attempted_at.blank? || last_sync_attempted_at < SYNC_STALE_TIMEOUT.ago)
end
def sync_in_progress?
sync_syncing? && !sync_stale?
end
private
def enqueue_crawl_job
+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
@@ -1,4 +1,11 @@
module Enterprise::Account
CAPTAIN_SYNC_INTERVALS = {
'hacker' => nil,
'startups' => 7.days,
'business' => 1.day,
'enterprise' => 6.hours
}.freeze
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
@@ -34,6 +41,14 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def captain_document_sync_interval
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
CAPTAIN_SYNC_INTERVALS[plan.downcase]
end
def saml_enabled?
saml_settings&.saml_enabled? || false
end
@@ -12,10 +12,6 @@ module Enterprise::Channel::TwilioSms
end
end
def voice_enabled?
voice_enabled
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
@@ -34,11 +30,7 @@ module Enterprise::Channel::TwilioSms
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
# Override: when api_key_secret is stored separately (voice channels),
# use it instead of auth_token for API key authentication.
# Existing SMS channels store the secret in auth_token — that path is unchanged via super.
# Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super.
def client
if api_key_sid.present? && api_key_secret.present?
Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid)
@@ -47,6 +39,8 @@ module Enterprise::Channel::TwilioSms
end
end
private
def voice_requires_phone_number
return if phone_number.present?
@@ -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
@@ -30,4 +36,13 @@ module Enterprise::Conversation
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
@@ -7,6 +7,12 @@ module Enterprise::Message
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
@@ -23,6 +23,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def sync?
@account_user.administrator?
end
def playground?
true
end
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
private
def firecrawl_configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
Captain::Tools::FirecrawlService.configured?
end
def fetch_with_firecrawl
@@ -52,14 +52,13 @@ class Captain::Documents::SinglePageFetcher
end
def fetch_with_fallback
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
crawler = Captain::Tools::SimplePageCrawlService.new(@url)
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
end
def perform
@document.store_sync_step('fetching')
@document.update!(sync_step: 'fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
@@ -20,17 +20,21 @@ class Captain::Documents::SyncService
raise_for_error_code(result.error_code)
end
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
previous_fingerprint = @document.content_fingerprint
if fingerprint == @document.content_fingerprint
if new_fingerprint == previous_fingerprint
mark_synced
return :unchanged
end
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
@document.update!(sync_step: 'updating')
update_content(result, new_fingerprint)
# Without a prior fingerprint we cannot tell a first-ever sync apart from a real
# change, so treat it as unchanged to keep downstream signals quiet on baseline.
previous_fingerprint.present? ? :updated : :unchanged
end
private
@@ -42,6 +46,7 @@ class Captain::Documents::SyncService
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
@@ -50,6 +55,7 @@ class Captain::Documents::SyncService
def mark_synced
@document.update!(
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -62,6 +68,7 @@ class Captain::Documents::SyncService
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -1,5 +1,6 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -30,7 +31,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def fetch_website_content
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
text_content = crawler.body_text_content
text_content = crawler.body_markdown
page_title = crawler.page_title
meta_description = crawler.meta_description
@@ -2,9 +2,14 @@ class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
.present?
end
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
raise 'Missing API key' if @api_key.blank?
end
def perform(url, webhook_url, crawl_limit = 10)
@@ -1,12 +1,16 @@
class Captain::Tools::SimplePageCrawlService
attr_reader :external_link
attr_reader :external_link, :status_code
def initialize(external_link)
@external_link = external_link
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@parser = Captain::Tools::HtmlPageParser.new(fetch_body)
@doc = @parser.doc
end
def success?
status_code.to_i.between?(200, 299)
end
def page_links
sitemap? ? extract_links_from_sitemap : extract_links_from_html
end
@@ -15,7 +19,7 @@ class Captain::Tools::SimplePageCrawlService
@parser.title
end
def body_text_content
def body_markdown
@parser.body_markdown
end
@@ -35,6 +39,21 @@ class Captain::Tools::SimplePageCrawlService
private
def fetch_body
body = ''
SafeFetch.fetch(external_link, validate_content_type: false) do |result|
body = result.tempfile.read
end
@status_code = 200
body
rescue SafeFetch::HttpError => e
@status_code = e.message.to_i
''
rescue SafeFetch::Error
@status_code = nil
''
end
def sitemap?
@external_link.end_with?('.xml')
end
@@ -1,4 +1,9 @@
module Whatsapp::Providers::WhatsappCloudCallMethods
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
@@ -8,16 +13,16 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
end
def reject_call(call_id)
call_api('reject_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'reject' })
call_api('reject_call', call_action_body(call_id, 'reject'))
end
def terminate_call(call_id)
call_api('terminate_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'terminate' })
call_api('terminate_call', call_action_body(call_id, 'terminate'))
end
def send_call_permission_request(to_phone_number, body_text = 'We would like to call you regarding your conversation.')
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text).to_json
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
@@ -30,13 +35,19 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer).to_json
"#{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
@@ -44,7 +55,7 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
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?
@@ -60,26 +71,26 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
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?
parsed = response.parsed_response
error_code = parsed&.dig('error', 'code')
error_msg = parsed&.dig('error', 'error_user_msg') || 'Failed to initiate call'
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 == 138_006
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
raise StandardError, error_msg
raise Voice::CallErrors::CallFailed, error_msg
end
end
@@ -2,10 +2,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
# Whisper API rejects files larger than 25 MB. At Opus 48 kbps that is ~70
# minutes of audio — longer recordings keep the audio attachment but skip
# transcription rather than failing with an OpenAI 413.
WHISPER_BYTE_LIMIT = 25.megabytes
attr_reader :attachment, :message, :account
@@ -19,7 +15,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def perform
return { error: 'Transcription limit exceeded' } unless can_transcribe?
return { error: 'Message not found' } if message.blank?
return { error: 'Audio too large for Whisper' } if audio_too_large?
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
@@ -38,13 +33,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
account.usage_limits[:captain][:responses][:current_available].positive?
end
def audio_too_large?
blob = attachment.file&.blob
return false unless blob
blob.byte_size > WHISPER_BYTE_LIMIT
end
def fetch_audio_file
blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
@@ -75,16 +63,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
# temperature: 0.0 minimises Whisper's hallucinations on ambiguous or
# low-amplitude segments. The previous value (0.4) triggered spiraling
# repetitions like "Oh, dear. Oh, dear. Oh, dear." on silences and
# "No. No. No." / "Hello. Hello. Hello." on near-silent audio —
# well-documented Whisper behaviour at non-zero temperatures. 0.0
# matches OpenAI's own default recommendation.
response = @client.audio.transcribe(
parameters: {
model: WHISPER_MODEL,
file: file,
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
# higher temperatures produce phantom phrases that look like real transcripts.
temperature: 0.0
}
)
@@ -11,18 +11,18 @@ class Twilio::VoiceTeardownService
private
def delete_twiml_app
twilio_client.applications(channel.twiml_app_sid).delete
channel.client.applications(channel.twiml_app_sid).delete
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_number_webhooks
numbers = twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
return if numbers.empty?
twilio_client
.incoming_phone_numbers(numbers.first.sid)
.update(voice_url: '', status_callback: '')
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(voice_url: '', status_callback: '')
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
@@ -30,12 +30,4 @@ class Twilio::VoiceTeardownService
def clear_voice_credentials
channel.update(twiml_app_sid: nil)
end
def twilio_client
@twilio_client ||= if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
else
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
end
end
@@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService
private
def validate_token_credentials!
validation_client = channel.api_key_sid.present? ? api_key_client : token_client
validation_client.incoming_phone_numbers.list(limit: 1)
channel.client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
@@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService
def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = api_key_client.applications.create(
app = channel.client.applications.create(
friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD
@@ -38,33 +37,25 @@ class Twilio::VoiceWebhookSetupService
end
def configure_number_webhooks!
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
if numbers.empty?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return
end
api_key_client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise
end
def api_key_client
@api_key_client ||= ::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
end
def token_client
@token_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
def log_twilio_error(context, error)
details = build_error_details(context, error)
add_twilio_specific_details(details, error)
@@ -1,119 +1,52 @@
class Voice::CallMessageBuilder
# Maps Call model statuses to voice_call display statuses (hyphenated for frontend)
CALL_TO_VOICE_STATUS = {
'ringing' => 'ringing',
'in_progress' => 'in-progress',
'failed' => 'failed',
'no_answer' => 'no-answer',
'completed' => 'completed'
}.freeze
def self.create!(conversation:, call:, user: nil)
new(conversation: conversation, call: call, user: user).create!
end
# Backwards-compatible entry used by Voice::OutboundCallBuilder (Twilio).
def self.perform!(call:)
create!(conversation: call.conversation, call: call, user: call.accepted_by_agent)
end
def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil)
new(conversation: call.conversation, call: call).update_status!(
status: status, agent: agent, duration_seconds: duration_seconds
)
end
def initialize(conversation:, call:, user: nil)
@conversation = conversation
def initialize(call)
@call = call
@user = user
end
def create!
# Direct create rather than Messages::MessageBuilder because the latter
# rejects `incoming` messages on non-Api inboxes — that gate is meant for
# human messages flowing through chatbot-style integrations, but a voice
# call bubble is a system event that needs to live on the WhatsApp inbox.
Message.create!(
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
content: message_content,
message_type: message_type,
content_type: 'voice_call',
content_attributes: { 'data' => build_data_payload },
sender: sender
)
def perform!
call.message || create_message!
end
def update_status!(status:, agent: nil, duration_seconds: nil)
message = call.message
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = map_status(status) if status
data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent
data['data']['duration_seconds'] = duration_seconds if duration_seconds
patch = {
'status' => status&.to_s&.tr('_', '-'),
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
'duration_seconds' => duration_seconds
}.compact
message.update!(content_attributes: data)
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
message
end
private
attr_reader :conversation, :call, :user
attr_reader :call
# `call_source` lets the FE disambiguate WhatsApp vs Twilio for UI copy
# and event routing without loading the whole Call record client-side.
# `media_server_enabled` is hard-coded to false because WhatsApp now runs
# browser↔Meta WebRTC directly (no media server) and Twilio's bubble
# never gates on this flag. Rejoin is unsupported across the board.
def build_data_payload
{
'call_sid' => call.provider_call_id,
'status' => map_status(call.status),
'call_direction' => call.direction_label,
'call_source' => call.provider,
'call_id' => call.id,
'from_number' => from_number,
'to_number' => to_number,
'media_server_enabled' => false,
'meta' => { 'created_at' => Time.zone.now.to_i }
def create_message!
params = {
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call',
content_attributes: { 'data' => build_data_payload }
}
end
def message_content
call.twilio? ? 'Voice Call' : 'WhatsApp Call'
end
def message_type
call.outgoing? ? 'outgoing' : 'incoming'
Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
def sender
return user if call.outgoing? && user
conversation.contact
call.outgoing? ? call.accepted_by_agent : call.contact
end
def from_number
if call.incoming?
conversation.contact&.phone_number
else
conversation.inbox.channel&.phone_number
end
end
def to_number
if call.incoming?
conversation.inbox.channel&.phone_number
else
conversation.contact&.phone_number
end
end
def map_status(status)
CALL_TO_VOICE_STATUS[status] || status
# 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

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