Compare commits

...
Author SHA1 Message Date
Muhsin ab0370fbe1 refactor(voice): make the whole call bubble the join target and switch active calls
- The bubble itself is now the click target with an inline Join/Rejoin
  caption, matching the original design intent — discoverable on hover
  without a separate footer button.
- If the agent already has a different active call, end it first so the
  switch is clean (mirrors FloatingCallWidget.handleJoinCall).
2026-04-30 18:52:48 +04:00
Muhsin e415b0576a feat(voice): allow joining a call from the conversation timeline bubble
Agents who refresh or dismiss the floating widget can now click a Join /
Rejoin action on the voice_call bubble to (re)connect to a ringing or
in-progress call. The CTA respects the same visibility rules as the
floating widget — outbound calls only surface for the initiator, and
inbound assigned calls only surface for the assignee.

useCallSession gains a manageSessionState option so the bubble can reuse
joinCall/activeCall without registering duplicate Twilio listeners or
running its own duration timer.

Closes PLA-117
2026-04-30 18:46:09 +04:00
353089473e feat(voice): Assignment aware visibility and join conflict for inbound calls (#14333)
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.

Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on

### How to test

1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 18:38:10 +04: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
67 changed files with 1234 additions and 889 deletions
+1
View File
@@ -22,6 +22,7 @@ gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info' gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- 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) acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1) activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0) zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7) addressable (2.9.0)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 8.0)
administrate (0.20.1) administrate (0.20.1)
actionpack (>= 6.0, < 8.0) actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0)
@@ -349,6 +349,7 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a) googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.22.2) gli (2.22.2)
ostruct ostruct
@@ -676,7 +677,7 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (6.0.2) public_suffix (7.0.5)
puma (6.4.3) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
@@ -1075,6 +1076,7 @@ DEPENDENCIES
fcm fcm
flag_shih_tzu flag_shih_tzu
foreman foreman
gemoji
geocoder geocoder
gmail_xoauth gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0) google-cloud-dialogflow-v2 (>= 0.24.0)
+8 -1
View File
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH, GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
} }
end 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 def allowed_login_methods
methods = ['email'] methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -0,0 +1,9 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -0,0 +1,20 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
+2
View File
@@ -48,3 +48,5 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse messages.reorder('created_at desc').limit(20).reverse
end end
end end
MessageFinder.prepend_mod_with('MessageFinder')
+3
View File
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
@@ -27,6 +28,7 @@ export default {
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
StatusBanner,
PaymentPendingBanner, PaymentPendingBanner,
WootSnackbarBox, WootSnackbarBox,
PendingEmailVerificationBanner, PendingEmailVerificationBanner,
@@ -137,6 +139,7 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'" :dir="isRTL ? 'rtl' : 'ltr'"
> >
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" /> <UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId"> <template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" /> <PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" /> <PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null; this.inboxId = null;
} }
async joinClientCall({ to, conversationId }) { async joinClientCall({ to, conversationId, callSid }) {
if (!this.device || !this.initialized || !to) return null; if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection; if (this.activeConnection) return this.activeConnection;
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
To: to, To: to,
is_agent: 'true', is_agent: 'true',
conversation_id: conversationId, conversation_id: conversationId,
call_sid: callSid,
}; };
const connection = await this.device.connect({ params }); const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
} }
leaveConference(inboxId, conversationId) { leaveConference({ inboxId, conversationId, callSid }) {
return axios return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId }, params: { conversation_id: conversationId, call_sid: callSid },
}) })
.then(r => r.data); .then(r => r.data);
} }
@@ -35,10 +35,16 @@ const emit = defineEmits([
const lastMessageInChat = computed(() => getLastMessage(props.chat)); const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0); const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({ const voiceCallData = computed(() => {
status: props.chat.additional_attributes?.call_status, const last = lastMessageInChat.value;
direction: props.chat.additional_attributes?.call_direction, 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); const unreadCount = computed(() => props.chat.unread_count);
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => { const bannerClass = computed(() => {
const classMap = { const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11', slate:
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11', 'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11', amber:
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11', 'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-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]; return classMap[props.color];
@@ -113,6 +113,7 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value), validator: value => Object.values(MESSAGE_STATUS).includes(value),
}, },
attachments: { type: Array, default: () => [] }, attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null }, content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) }, contentAttributes: { type: Object, default: () => ({}) },
contentType: { contentType: {
@@ -1,7 +1,11 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants'; import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { canCurrentUserJoinCall } from 'dashboard/helper/voice';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -11,11 +15,6 @@ const LABEL_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED', [VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
}; };
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = { const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,15 +29,50 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
}; };
const { contentAttributes, messageType } = useMessageContext(); const JOINABLE_STATUSES = [
VOICE_CALL_STATUS.RINGING,
VOICE_CALL_STATUS.IN_PROGRESS,
];
const data = computed(() => contentAttributes.value?.data); const { t } = useI18n();
const status = computed(() => data.value?.status?.toString()); const store = useStore();
const { call, conversationId, currentUserId, inboxId, sender } =
useMessageContext();
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
useCallSession({
manageSessionState: false,
});
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() => const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
); );
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.accepted_by_agent_name)
return call.value.accepted_by_agent_name;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => { const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
@@ -52,16 +86,28 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}); });
const subtextKey = computed(() => { const subtext = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value]; if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED' if (didCurrentUserAnswer.value) {
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED'; return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
} }
return isFailed.value return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER' ? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'; : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}); });
const iconName = computed(() => { const iconName = computed(() => {
@@ -70,11 +116,83 @@ const iconName = computed(() => {
}); });
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.provider_call_id);
const isAlreadyOnThisCall = computed(
() => !!callSid.value && activeCall.value?.callSid === callSid.value
);
const resolvedInboxId = computed(() => {
if (inboxId?.value) return inboxId.value;
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.inbox_id || null;
});
const conversationForVisibility = computed(() =>
store.getters.getConversationById?.(conversationId?.value)
);
const isVisibleToCurrentUser = computed(() =>
canCurrentUserJoinCall({
call: call.value,
conversation: conversationForVisibility.value,
senderId: sender?.value?.id,
currentUserId: currentUserId?.value,
})
);
const canJoin = computed(
() =>
JOINABLE_STATUSES.includes(status.value) &&
!!callSid.value &&
!!resolvedInboxId.value &&
!!conversationId?.value &&
!isAlreadyOnThisCall.value &&
isVisibleToCurrentUser.value
);
const joinLabel = computed(() =>
status.value === VOICE_CALL_STATUS.IN_PROGRESS && didCurrentUserAnswer.value
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
: t('CONVERSATION.VOICE_CALL.JOIN_CALL')
);
const activeConversation = computed(() => {
const id = activeCall.value?.conversationId;
return id ? store.getters.getConversationById?.(id) : null;
});
const handleJoinClick = async () => {
if (!canJoin.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
const activeInboxId = activeConversation.value?.inbox_id;
if (activeCall.value?.conversationId && activeInboxId) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeInboxId,
callSid: activeCall.value.callSid,
});
}
}
await joinCall({
conversationId: conversationId.value,
inboxId: resolvedInboxId.value,
callSid: callSid.value,
});
};
</script> </script>
<template> <template>
<BaseBubble class="p-0 border-none" hide-meta> <BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs"> <button
type="button"
:disabled="!canJoin || isJoining"
data-test-id="voice-call-join"
class="flex overflow-hidden flex-col w-full max-w-xs text-left bg-transparent border-none"
:class="{
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
canJoin,
'cursor-default': !canJoin,
}"
@click="handleJoinClick"
>
<div class="flex gap-3 items-center p-3 w-full"> <div class="flex gap-3 items-center p-3 w-full">
<div <div
class="flex justify-center items-center rounded-full size-10 shrink-0" class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -95,10 +213,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }} {{ $t(labelKey) }}
</span> </span>
<span class="text-xs text-n-slate-11"> <span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }} {{ subtext }}
</span>
<span v-if="canJoin" class="mt-1 text-xs font-medium text-n-teal-10">
{{ joinLabel }}
</span> </span>
</div> </div>
</div> </div>
</div> </button>
</BaseBubble> </BaseBubble>
</template> </template>
@@ -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> <script>
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
await endCallSession({ await endCallSession({
conversationId: call.conversationId, conversationId: call.conversationId,
inboxId, inboxId,
callSid: call.callSid,
}); });
}; };
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0); const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat)); const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({ const voiceCallData = computed(() => {
status: props.chat.additional_attributes?.call_status, const last = lastMessageInChat.value;
direction: props.chat.additional_attributes?.call_direction, if (last?.content_type !== 'voice_call' || !last.call) {
})); return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => { const showMetaSection = computed(() => {
return ( return (
@@ -1,11 +1,18 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue'; import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient'; import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer'; import Timer from 'dashboard/helper/Timer';
export function useCallSession() { // `manageSessionState: false` lets sub-consumers (like the timeline VoiceCall
// bubble) reuse joinCall/activeCall without registering duplicate Twilio
// listeners or running their own duration timer — only the floating widget
// owns the session lifecycle.
export function useCallSession({ manageSessionState = true } = {}) {
const callsStore = useCallsStore(); const callsStore = useCallsStore();
const { t } = useI18n();
const isJoining = ref(false); const isJoining = ref(false);
const callDuration = ref(0); const callDuration = ref(0);
const durationTimer = new Timer(elapsed => { const durationTimer = new Timer(elapsed => {
@@ -16,36 +23,38 @@ export function useCallSession() {
const incomingCalls = computed(() => callsStore.incomingCalls); const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall); const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch( if (manageSessionState) {
hasActiveCall, watch(
active => { hasActiveCall,
if (active) { active => {
durationTimer.start(); if (active) {
} else { durationTimer.start();
durationTimer.stop(); } else {
callDuration.value = 0; durationTimer.stop();
} callDuration.value = 0;
}, }
{ immediate: true } },
); { immediate: true }
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
); );
});
onUnmounted(() => { onMounted(() => {
durationTimer.stop(); TwilioVoiceClient.addEventListener('call:disconnected', () =>
TwilioVoiceClient.removeEventListener('call:disconnected', () => callsStore.clearActiveCall()
callsStore.clearActiveCall() );
); });
});
const endCall = async ({ conversationId, inboxId }) => { onUnmounted(() => {
await VoiceAPI.leaveConference(inboxId, conversationId); durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
}
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall(); TwilioVoiceClient.endClientCall();
durationTimer.stop(); if (manageSessionState) durationTimer.stop();
callsStore.clearActiveCall(); callsStore.clearActiveCall();
}; };
@@ -66,13 +75,19 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({ await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid, to: joinResponse?.conference_sid,
conversationId, conversationId,
callSid,
}); });
callsStore.setCallActive(callSid); callsStore.setCallActive(callSid);
durationTimer.start(); if (manageSessionState) durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid }; return { conferenceSid: joinResponse?.conference_sid };
} catch (error) { } catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Failed to join call:', error); console.error('Failed to join call:', error);
return null; return null;
@@ -1,5 +1,6 @@
export const LOCAL_STORAGE_KEYS = { export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates', DISMISSED_UPDATES: 'dismissedUpdates',
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
WIDGET_BUILDER: 'widgetBubble_', WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages', DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme', COLOR_SCHEME: 'color_scheme',
+90 -15
View File
@@ -22,13 +22,55 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId; return callDirection === 'outbound' && senderId !== currentUserId;
}; };
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Whether the current user is allowed to take over / join an existing call.
// Mirrors the floating-widget visibility rules so the bubble's join action
// stays in sync.
export const canCurrentUserJoinCall = ({
call,
conversation,
senderId,
currentUserId,
}) => {
if (!call) return false;
return shouldShowCall({
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
senderId,
assigneeId: extractAssigneeId(conversation),
currentUserId,
});
};
function extractCallData(message) { function extractCallData(message) {
const contentData = message?.content_attributes?.data || {}; const call = message?.call || {};
return { return {
callSid: contentData.call_sid, callSid: call.provider_call_id,
status: contentData.status, status: call.status,
callDirection: contentData.call_direction, callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id, conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id, senderId: message?.sender?.id,
}; };
} }
@@ -36,10 +78,19 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) { export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return; if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } = const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message); extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return; if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore(); const callsStore = useCallsStore();
callsStore.addCall({ callsStore.addCall({
@@ -53,22 +104,38 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) { export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return; if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } = const {
extractCallData(message); callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore(); const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId }); callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status }; commit(types.UPDATE_MESSAGE_CALL_STATUS, {
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo); conversationId,
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo); callStatus: status,
callSid,
});
const isNewCall = if (
status === 'ringing' && !shouldShowCall({
!shouldSkipCall(callDirection, senderId, currentUserId); callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (isNewCall) { if (status === 'ringing') {
callsStore.addCall({ callsStore.addCall({
callSid, callSid,
conversationId, conversationId,
@@ -77,3 +144,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
}); });
} }
} }
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -83,7 +83,10 @@
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered" "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call",
"REJOIN_CALL": "Rejoin call"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
import { import {
handleVoiceCallCreated, handleVoiceCallCreated,
handleVoiceCallUpdated, handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice'; } from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => { export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -393,19 +394,18 @@ const actions = {
} }
}, },
updateConversation({ commit, dispatch }, conversation) { updateConversation({ commit, dispatch, rootGetters }, conversation) {
const { const sender = conversation.meta?.sender;
meta: { sender },
} = conversation;
commit(types.UPDATE_CONVERSATION, conversation); commit(types.UPDATE_CONVERSATION, conversation);
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', { dispatch('conversationLabels/setConversationLabel', {
id: conversation.id, id: conversation.id,
data: conversation.labels, data: conversation.labels,
}); });
dispatch('contacts/setContact', sender); if (sender) dispatch('contacts/setContact', sender);
}, },
updateConversationLastActivity( updateConversationLastActivity(
@@ -307,34 +307,21 @@ export const mutations = {
} }
}, },
[types.UPDATE_CONVERSATION_CALL_STATUS]( [types.UPDATE_MESSAGE_CALL_STATUS](
_state, _state,
{ conversationId, callStatus } { conversationId, callStatus, callSid }
) { ) {
const chat = getConversationById(_state)(conversationId); const chat = getConversationById(_state)(conversationId);
if (!chat) return; if (!chat) return;
chat.additional_attributes = { const message = (chat.messages || []).find(
...chat.additional_attributes, m =>
call_status: callStatus, m.content_type === CONTENT_TYPES.VOICE_CALL &&
}; m.call?.provider_call_id === callSid
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
); );
if (!message?.call) return;
if (!lastCall) return; message.call = { ...message.call, status: callStatus };
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
}, },
[types.SET_ACTIVE_INBOX](_state, inboxId) { [types.SET_ACTIVE_INBOX](_state, inboxId) {
@@ -2,55 +2,18 @@ import { mutations } from '../index';
import types from '../../../mutation-types'; import types from '../../../mutation-types';
describe('#mutations', () => { describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => { describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => { it('does nothing if conversation is not found', () => {
const state = { allConversations: [] }; const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, { mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1, conversationId: 1,
callStatus: 'ringing', callStatus: 'ringing',
callSid: 'CA123',
}); });
expect(state.allConversations).toEqual([]); expect(state.allConversations).toEqual([]);
}); });
it('does nothing if no voice call message exists', () => { it('does nothing if no matching voice call message exists', () => {
const state = { const state = {
allConversations: [ allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] }, { id: 1, messages: [{ id: 1, content_type: 'text' }] },
@@ -59,6 +22,7 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, { mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1, conversationId: 1,
callStatus: 'ringing', callStatus: 'ringing',
callSid: 'CA123',
}); });
expect(state.allConversations[0].messages[0]).toEqual({ expect(state.allConversations[0].messages[0]).toEqual({
id: 1, id: 1,
@@ -66,7 +30,7 @@ describe('#mutations', () => {
}); });
}); });
it('updates the last voice call message status', () => { it('updates only the voice call message matching the given callSid', () => {
const state = { const state = {
allConversations: [ allConversations: [
{ {
@@ -75,12 +39,12 @@ describe('#mutations', () => {
{ {
id: 1, id: 1,
content_type: 'voice_call', content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } }, call: { provider_call_id: 'CA111', status: 'ringing' },
}, },
{ {
id: 2, id: 2,
content_type: 'voice_call', content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } }, call: { provider_call_id: 'CA222', status: 'ringing' },
}, },
], ],
}, },
@@ -89,34 +53,15 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, { mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1, conversationId: 1,
callStatus: 'in-progress', callStatus: 'in-progress',
callSid: 'CA111',
}); });
expect( expect(state.allConversations[0].messages[0].call.status).toBe(
state.allConversations[0].messages[0].content_attributes.data.status 'in-progress'
).toBe('ringing'); );
expect( expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
}); });
it('creates content_attributes.data if it does not exist', () => { it('preserves existing call fields when updating status', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = { const state = {
allConversations: [ allConversations: [
{ {
@@ -125,8 +70,11 @@ describe('#mutations', () => {
{ {
id: 1, id: 1,
content_type: 'voice_call', content_type: 'voice_call',
content_attributes: { call: {
data: { call_sid: 'CA123', status: 'ringing' }, provider_call_id: 'CA123',
status: 'ringing',
direction: 'incoming',
duration_seconds: null,
}, },
}, },
], ],
@@ -136,12 +84,13 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, { mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1, conversationId: 1,
callStatus: 'in-progress', callStatus: 'in-progress',
callSid: 'CA123',
}); });
expect( expect(state.allConversations[0].messages[0].call).toEqual({
state.allConversations[0].messages[0].content_attributes.data provider_call_id: 'CA123',
).toEqual({
call_sid: 'CA123',
status: 'in-progress', status: 'in-progress',
direction: 'incoming',
duration_seconds: null,
}); });
}); });
@@ -152,8 +101,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, { mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1, conversationId: 1,
callStatus: 'ringing', callStatus: 'ringing',
callSid: 'CA123',
}); });
expect(state.allConversations[0].messages).toEqual([]); expect(state.allConversations[0].messages).toEqual([]);
}); });
it('does nothing if matching message has no call object yet', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA-OTHER' },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
callSid: 'CA-MISSING',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA-OTHER',
});
});
}); });
}); });
@@ -52,7 +52,6 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES: UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES', 'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY', UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS', UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES', SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
+14
View File
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
dismissCall(callSid) { dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid); this.calls = this.calls.filter(call => call.callSid !== callSid);
}, },
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
}, },
}); });
@@ -24,6 +24,7 @@ const {
WIDGET_BRAND_URL: widgetBrandURL, WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv, DEPLOYMENT_ENV: deploymentEnv,
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
} = window.globalConfig || {}; } = window.globalConfig || {};
const state = { const state = {
@@ -49,6 +50,7 @@ const state = {
termsURL, termsURL,
widgetBrandURL, widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise), isEnterprise: parseBoolean(isEnterprise),
activePlatformBanners: activePlatformBanners || [],
}; };
export const getters = { export const getters = {
+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
+6
View File
@@ -50,6 +50,8 @@ class Messages::MentionService
def generate_notifications_for_mentions(validated_mentioned_ids) def generate_notifications_for_mentions(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id| validated_mentioned_ids.each do |user_id|
next if self_mention?(user_id)
NotificationBuilder.new( NotificationBuilder.new(
notification_type: 'conversation_mention', notification_type: 'conversation_mention',
user: User.find(user_id), user: User.find(user_id),
@@ -60,6 +62,10 @@ class Messages::MentionService
end end
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) def add_mentioned_users_as_participants(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id| validated_mentioned_ids.each do |user_id|
message.conversation.conversation_participants.find_or_create_by(user_id: user_id) message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
@@ -12,3 +12,5 @@ json.private message.private
json.source_id message.source_id json.source_id message.source_id
json.sender message.sender.push_event_data if message.sender json.sender message.sender.push_event_data if message.sender
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present? json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
json.set! :call, message.call.push_event_data if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
@@ -174,6 +174,10 @@
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/> <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>
<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"> <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"/> <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> </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', users: 'icon-user-follow-line',
platform_apps: 'icon-apps-2-line', platform_apps: 'icon-apps-2-line',
agent_bots: 'icon-robot-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' } %> <%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %> <% 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 ["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: { <%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym], icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource), url: resource_index_route(resource),
+2
View File
@@ -64,6 +64,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}' email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again' invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed failed: Signup failed
voice:
call_already_accepted: '%{agent_name} is already handling the call.'
assignment_policy: assignment_policy:
not_found: Assignment policy not found not_found: Assignment policy not found
attachments: attachments:
+1
View File
@@ -653,6 +653,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member, action: :destroy_avatar delete :avatar, on: :member, action: :destroy_avatar
end end
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy] resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
resources :platform_banners
resource :instance_status, only: [:show] resource :instance_status, only: [:show]
resource :settings, only: [:show] do resource :settings, only: [:show] do
@@ -0,0 +1,11 @@
class CreatePlatformBanners < ActiveRecord::Migration[7.1]
def change
create_table :platform_banners do |t|
t.text :banner_message, null: false
t.integer :banner_type, null: false, default: 0
t.boolean :active, default: true, null: false
t.timestamps
end
end
end
+8
View File
@@ -1087,6 +1087,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end 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| create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false t.integer "account_id", null: false
t.string "name", null: false t.string "name", null: false
@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference before_action :set_voice_inbox_for_conference
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
def token def token
render json: Voice::Provider::Twilio::TokenService.new( render json: Voice::Provider::Twilio::TokenService.new(
@@ -10,36 +11,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
end end
def create def create
conversation = fetch_conversation_by_display_id call = resolve_call!
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation) conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
conference_sid = conference_service.ensure_conference_sid conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user) conference_service.mark_agent_joined(user: current_user)
render json: { render json: {
status: 'success', status: 'success',
id: conversation.display_id, id: call.conversation.display_id,
conference_sid: conference_sid, conference_sid: conference_sid,
using_webrtc: true using_webrtc: true
} }
end end
def destroy def destroy
conversation = fetch_conversation_by_display_id call = resolve_call!
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: conversation.display_id } render json: { status: 'success', id: call.conversation.display_id }
end end
private private
def ensure_call_sid!(conversation) def resolve_call!
return conversation.identifier if conversation.identifier.present? sid = params[:call_sid].presence
raise ActionController::ParameterMissing, :call_sid if sid.blank?
incoming_sid = params.require(:call_sid) conversation = fetch_conversation_by_display_id
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
conversation.update!(identifier: incoming_sid) .find_by!(provider_call_id: sid)
incoming_sid
end end
def set_voice_inbox_for_conference def set_voice_inbox_for_conference
@@ -55,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
authorize conversation, :show? authorize conversation, :show?
conversation conversation
end end
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
end end
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
authorize contact, :show? authorize contact, :show?
authorize voice_inbox, :show? authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!( call = Voice::OutboundCallBuilder.perform!(
account: Current.account, account: Current.account,
inbox: voice_inbox, inbox: voice_inbox,
user: Current.user, user: Current.user,
contact: contact contact: contact
) )
conversation = result[:conversation]
render json: { render json: {
conversation_id: conversation.display_id, conversation_id: call.conversation.display_id,
inbox_id: voice_inbox.id, inbox_id: voice_inbox.id,
call_sid: result[:call_sid], call_sid: call.provider_call_id,
conference_sid: conversation.additional_attributes['conference_sid'] conference_sid: call.conference_sid
} }
end end
@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
end end
def call_twiml def call_twiml
account = current_account
Rails.logger.info( Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}" "TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
) )
conversation = resolve_conversation call = resolve_call
conference_sid = ensure_conference_sid!(conversation) render xml: conference_twiml(call)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
end end
def conference_status def conference_status
event = mapped_conference_event event = mapped_conference_event
return head :no_content unless event if event.nil?
Rails.logger.info(
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
)
return head :no_content
end
conversation = find_conversation_for_conference!( call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
Voice::Conference::Manager.new( Voice::Conference::Manager.new(
conversation: conversation, call: call,
event: event, event: event,
call_sid: twilio_call_sid,
participant_label: participant_label participant_label: participant_label
).process ).process
@@ -80,8 +78,8 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:') from_number.start_with?('client:')
end end
def resolve_conversation def resolve_call
return find_conversation_for_agent if agent_leg?(twilio_from) return find_call_for_agent if agent_leg?(twilio_from)
case twilio_direction case twilio_direction
when 'inbound' when 'inbound'
@@ -92,79 +90,72 @@ class Twilio::VoiceController < ApplicationController
call_sid: twilio_call_sid call_sid: twilio_call_sid
) )
when 'outbound-api', 'outbound-dial' when 'outbound-api', 'outbound-dial'
sync_outbound_leg( sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
else else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}" raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end end
end end
def find_conversation_for_agent def find_call_for_agent
if params[:conversation_id].present? sid = params[:call_sid].presence
current_account.conversations.find_by!(display_id: params[:conversation_id]) raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
else
current_account.conversations.find_by!(identifier: twilio_call_sid) inbox_calls.find_by!(provider_call_id: sid)
end
end end
def sync_outbound_leg(call_sid:, from_number:, direction:) def sync_outbound_leg(call_sid:, direction:)
parent_sid = params['ParentCallSid'].presence parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid) call = inbox_calls.find_by!(provider_call_id: lookup_sid)
Voice::CallSessionSyncService.new( call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
conversation: conversation, call
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
end end
def ensure_conference_sid!(conversation) def inbox_calls
attrs = conversation.additional_attributes || {} Call.where(inbox_id: inbox.id, provider: :twilio)
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
end end
def conference_twiml(conference_sid, agent_leg) def conference_twiml(call)
conference_sid = ensure_conference_sid!(call)
Twilio::TwiML::VoiceResponse.new.tap do |response| Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial| response.dial do |dial|
dial.conference( dial.conference(
conference_sid, conference_sid,
start_conference_on_enter: agent_leg, start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false, end_conference_on_exit: false,
status_callback: conference_status_callback_url, status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave', status_callback_event: 'start end join leave',
status_callback_method: 'POST', status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact' participant_label: participant_label_for(twilio_from)
) )
end end
end.to_s end.to_s
end end
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def participant_label_for(from_number)
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
'contact'
end
def conference_status_callback_url def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+') phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits) Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end end
def find_conversation_for_conference!(friendly_name:, call_sid:) def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s name = friendly_name.to_s
scope = current_account.conversations call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
end end
def set_inbox! def set_inbox!
@@ -0,0 +1,5 @@
module Enterprise::MessageFinder
def conversation_messages
super.includes(call: [:contact, { inbox: :channel }])
end
end
+50 -1
View File
@@ -34,6 +34,8 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again # Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
enum :provider, { twilio: 0, whatsapp: 1 } enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 } enum :direction, { incoming: 0, outgoing: 1 }
@@ -41,7 +43,7 @@ class Call < ApplicationRecord
belongs_to :inbox belongs_to :inbox
belongs_to :conversation belongs_to :conversation
belongs_to :contact belongs_to :contact
belongs_to :message, optional: true belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording has_one_attached :recording
@@ -52,4 +54,51 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES } validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) } scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
def default_conference_sid
"conf_account_#{account_id}_call_#{id}"
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end end
@@ -25,17 +25,6 @@ module Enterprise::Conversation
self.captain_activity_reason_type = previous_reason_type self.captain_activity_reason_type = previous_reason_type
end end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
attrs_change = previous_changes['additional_attributes']
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private private
def dispatch_captain_inference_event(event_name) def dispatch_captain_inference_event(event_name)
@@ -1,4 +1,18 @@
module Enterprise::Message module Enterprise::Message
def self.prepended(base)
base.class_eval do
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
end
end
def push_event_data
data = super
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
data
end
private private
def mark_pending_conversation_as_open_for_human_response def mark_pending_conversation_as_open_for_human_response
@@ -1,90 +1,26 @@
class Voice::CallMessageBuilder class Voice::CallMessageBuilder
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {}) def initialize(call)
new( @call = call
conversation: conversation,
direction: direction,
payload: payload,
user: user,
timestamps: timestamps
).perform!
end
def initialize(conversation:, direction:, payload:, user:, timestamps:)
@conversation = conversation
@direction = direction
@payload = payload
@user = user
@timestamps = timestamps
end end
def perform! def perform!
validate_sender! call.message || create_message!
message = latest_message
message ? update_message!(message) : create_message!
end end
private private
attr_reader :conversation, :direction, :payload, :user, :timestamps attr_reader :call
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
end
def update_message!(message)
message.update!(
message_type: message_type,
content_attributes: { 'data' => base_payload },
sender: sender
)
end
def create_message! def create_message!
params = { params = {
content: 'Voice Call', content: 'Voice Call',
message_type: message_type, message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call', content_type: 'voice_call'
content_attributes: { 'data' => base_payload }
} }
Messages::MessageBuilder.new(sender, conversation, params).perform Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
def base_payload
@base_payload ||= begin
data = payload.slice(
:call_sid,
:status,
:call_direction,
:conference_sid,
:from_number,
:to_number
).stringify_keys
data['call_direction'] = direction
data['meta'] = {
'created_at' => timestamps[:created_at] || current_timestamp,
'ringing_at' => timestamps[:ringing_at] || current_timestamp
}.compact
data
end
end
def message_type
direction == 'outbound' ? 'outgoing' : 'incoming'
end end
def sender def sender
return user if direction == 'outbound' call.outgoing? ? call.accepted_by_agent : call.contact
conversation.contact
end
def validate_sender!
return unless direction == 'outbound'
raise ArgumentError, 'Agent sender required for outbound calls' unless user
end
def current_timestamp
@current_timestamp ||= Time.zone.now.to_i
end end
end end
@@ -1,94 +0,0 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
@conversation = conversation
@call_sid = call_sid
@message_call_sid = message_call_sid || call_sid
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
end
def perform
ActiveRecord::Base.transaction do
attrs = refreshed_attributes
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
sync_voice_call_message!(attrs)
end
conversation
end
private
def refreshed_attributes
attrs = (conversation.additional_attributes || {}).dup
attrs['call_direction'] = direction
attrs['call_status'] ||= 'ringing'
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
end
def sync_voice_call_message!(attrs)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
to_number: target_number_for(direction)
},
user: agent_for(attrs),
timestamps: {
created_at: attrs.dig('meta', 'initiated_at'),
ringing_at: attrs.dig('meta', 'ringing_at')
}
)
end
def origin_number_for(current_direction)
return outbound_origin if current_direction == 'outbound'
from_number.presence || inbox_number
end
def target_number_for(current_direction)
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
to_number || conversation.contact&.phone_number
end
def agent_for(attrs)
agent_id = attrs['agent_id']
return nil unless agent_id
agent = conversation.account.users.find_by(id: agent_id)
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
agent
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
def outbound_origin
inbox_number || from_number
end
def inbox_number
conversation.inbox&.channel&.phone_number
end
end
@@ -1,66 +1,44 @@
class Voice::CallStatus::Manager class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid] pattr_initialize [:call!]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
def process_status_update(status, duration: nil, timestamp: nil) def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status) return unless Call::STATUSES.include?(status)
return if call.status == status
current_status = conversation.additional_attributes&.dig('call_status') apply_call_updates!(status, duration: duration, timestamp: timestamp)
return if current_status == status call.conversation.update!(last_activity_at: Time.zone.now)
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
apply_status(status, duration: duration, timestamp: timestamp) call.message&.touch # rubocop:disable Rails/SkipsModelValidations
update_message(status)
end end
private private
def apply_status(status, duration:, timestamp:) def apply_call_updates!(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup attrs = { status: status }
attrs['call_status'] = status ts = timestamp || now_seconds
if status == 'in-progress' if status == 'in_progress'
attrs['call_started_at'] ||= timestamp || now_seconds # Twilio can emit multiple in-progress updates (answered + in-progress, retries).
elsif TERMINAL_STATUSES.include?(status) # Keep the earliest timestamp so duration_seconds doesn't shift forward.
attrs['call_ended_at'] = timestamp || now_seconds started_at = Time.zone.at(ts)
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp) attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
elsif Call::TERMINAL_STATUSES.include?(status)
call.ended_at = ts
attrs[:meta] = call.meta
attrs[:duration_seconds] = resolved_duration(duration, ts)
end end
conversation.update!( call.update!(attrs)
additional_attributes: attrs,
last_activity_at: current_time
)
end end
def resolved_duration(attrs, provided_duration, timestamp) def resolved_duration(provided_duration, timestamp)
return provided_duration if provided_duration return provided_duration if provided_duration
return unless call.started_at
started_at = attrs['call_started_at'] [timestamp - call.started_at.to_i, 0].max
return unless started_at && timestamp
[timestamp - started_at.to_i, 0].max
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = status
message.update!(content_attributes: data)
end end
def now_seconds def now_seconds
current_time.to_i Time.zone.now.to_i
end
def current_time
@current_time ||= Time.zone.now
end end
end end
@@ -1,71 +1,93 @@
class Voice::Conference::Manager class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label] pattr_initialize [:call!, :event!, :participant_label]
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
def process def process
case event case event
when 'start' when 'start'
ensure_conference_sid!
mark_ringing! mark_ringing!
when 'join' when 'join'
mark_in_progress! if agent_participant? join_agent! if agent_participant?
when 'leave' when 'leave'
handle_leave! handle_leave!
when 'end' when 'end'
finalize_conference! finalize!
end end
end end
private private
def status_manager def status_manager
@status_manager ||= Voice::CallStatus::Manager.new( @status_manager ||= Voice::CallStatus::Manager.new(call: call)
conversation: conversation,
call_sid: call_sid
)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
end end
def mark_ringing! def mark_ringing!
return if current_status # Guard against delayed conference-start retries rolling a progressed call back to ringing.
return unless call.status == 'ringing'
status_manager.process_status_update('ringing') status_manager.process_status_update('ringing')
end end
def mark_in_progress! def join_agent!
status_manager.process_status_update('in-progress', timestamp: current_timestamp) user_id = extract_user_id
claim_for_user!(user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# First-join wins; later joins by other agents are silently ignored so the
# webhook doesn't stomp the original assignee. User-facing rejection happens
# at the API layer.
def claim_for_user!(user_id)
claimed = false
call.with_lock do
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
claimed = true
end
auto_assign_conversation!(user_id) if claimed
end
def auto_assign_conversation!(user_id)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
def extract_user_id
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
return unless match
return unless match[2].to_i == call.account_id
match[1].to_i
end end
def handle_leave! def handle_leave!
case current_status case call.status
when 'ringing' when 'ringing'
status_manager.process_status_update('no-answer', timestamp: current_timestamp) status_manager.process_status_update('no_answer', timestamp: now)
when 'in-progress' when 'in_progress'
status_manager.process_status_update('completed', timestamp: current_timestamp) status_manager.process_status_update('completed', timestamp: now)
end end
end end
def finalize_conference! def finalize!
return if %w[completed no-answer failed].include?(current_status) return if Call::TERMINAL_STATUSES.include?(call.status)
status_manager.process_status_update('completed', timestamp: current_timestamp) status_manager.process_status_update('completed', timestamp: now)
end
def current_status
conversation.additional_attributes&.dig('call_status')
end end
def agent_participant? def agent_participant?
participant_label.to_s.start_with?('agent') participant_label.to_s.start_with?('agent-')
end end
def current_timestamp def now
Time.zone.now.to_i Time.zone.now.to_i
end end
end end
@@ -1,5 +0,0 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
end
end
@@ -13,21 +13,30 @@ class Voice::InboundCallBuilder
end end
def perform! def perform!
timestamp = current_timestamp existing = find_existing_call
return existing if existing
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
contact = ensure_contact! contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact) contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox) conversation = resolve_conversation!(contact, contact_inbox)
conversation.reload call = create_call!(contact, conversation)
update_conversation!(conversation, timestamp) message = Voice::CallMessageBuilder.new(call).perform!
build_voice_message!(conversation, timestamp) call.update!(message_id: message.id)
conversation call
end end
rescue ActiveRecord::RecordNotUnique
# A concurrent Twilio retry won the create race; return what now exists.
find_existing_call || raise
end end
private private
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: :twilio, provider_call_id: call_sid)
end
def ensure_contact! def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record| account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank? record.name = from_number if record.name.blank?
@@ -43,57 +52,37 @@ class Voice::InboundCallBuilder
end end
end end
def find_conversation def resolve_conversation!(contact, contact_inbox)
return if call_sid.blank? if inbox.lock_to_single_conversation
reusable = account.conversations
.where(contact_id: contact.id, inbox_id: inbox.id)
.where.not(status: :resolved)
.order(last_activity_at: :desc)
.first
return reusable if reusable
end
account.conversations.includes(:contact).find_by(identifier: call_sid)
end
def create_conversation!(contact, contact_inbox)
account.conversations.create!( account.conversations.create!(
contact_inbox_id: contact_inbox.id, contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id, inbox_id: inbox.id,
contact_id: contact.id, contact_id: contact.id,
status: :open, status: :open
identifier: call_sid
) )
end end
def update_conversation!(conversation, timestamp) def create_call!(contact, conversation)
attrs = { call = Call.create!(
'call_direction' => 'inbound', account: account,
'call_status' => 'ringing', inbox: inbox,
'conference_sid' => Voice::Conference::Name.for(conversation),
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation, conversation: conversation,
direction: 'inbound', contact: contact,
payload: { provider: :twilio,
call_sid: call_sid, direction: :incoming,
status: 'ringing', status: 'ringing',
conference_sid: conversation.additional_attributes['conference_sid'], provider_call_id: call_sid,
from_number: from_number, meta: { 'initiated_at' => Time.zone.now.to_i }
to_number: inbox.channel&.phone_number
},
timestamps: { created_at: timestamp, ringing_at: timestamp }
) )
end call.update!(conference_sid: call.default_conference_sid)
call
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end end
end end
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank? raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank? raise ArgumentError, 'Agent required' if user.blank?
timestamp = current_timestamp
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox! contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox) conversation = create_conversation!(contact_inbox)
conversation.reload
conference_sid = Voice::Conference::Name.for(conversation)
call_sid = initiate_call! call_sid = initiate_call!
update_conversation!(conversation, call_sid, conference_sid, timestamp) call = create_call!(conversation, call_sid)
build_voice_message!(conversation, call_sid, conference_sid, timestamp) message = Voice::CallMessageBuilder.new(call).perform!
{ conversation: conversation, call_sid: call_sid } call.update!(message_id: message.id)
call
end end
end end
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
end end
def initiate_call! def initiate_call!
inbox.channel.initiate_call( inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
to: contact.phone_number
)[:call_sid]
end end
def update_conversation!(conversation, call_sid, conference_sid, timestamp) def create_call!(conversation, call_sid)
attrs = { call = Call.create!(
'call_direction' => 'outbound', account: account,
'call_status' => 'ringing', inbox: inbox,
'agent_id' => user.id,
'conference_sid' => conference_sid,
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation, conversation: conversation,
direction: 'outbound', contact: contact,
payload: { accepted_by_agent: user,
call_sid: call_sid, provider: :twilio,
status: 'ringing', direction: :outgoing,
conference_sid: conference_sid, status: 'ringing',
from_number: inbox.channel&.phone_number, provider_call_id: call_sid,
to_number: contact.phone_number meta: { 'initiated_at' => Time.zone.now.to_i }
},
user: user,
timestamps: { created_at: timestamp, ringing_at: timestamp }
) )
end call.update!(conference_sid: call.default_conference_sid)
call
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end end
end end
@@ -1,35 +1,51 @@
class Voice::Provider::Twilio::ConferenceService class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!] pattr_initialize [:call!]
def ensure_conference_sid def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid') return call.conference_sid if call.conference_sid.present?
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation) call.update!(conference_sid: call.default_conference_sid)
merge_attributes('conference_sid' => sid) call.conference_sid
sid
end end
def mark_agent_joined(user:) def mark_agent_joined(user:)
merge_attributes( claim_call!(user)
'agent_joined' => true, assign_conversation!(user)
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end end
def end_conference def end_conference
client = conversation.inbox.channel.client return if call.conference_sid.blank?
client = call.inbox.channel.client
client client
.conferences .conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress') .list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') } .each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end end
private private
def merge_attributes(attrs) def claim_call!(user)
current = conversation.additional_attributes || {} call.with_lock do
conversation.update!(additional_attributes: current.merge(attrs)) raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
end
end
def claimed_by_other_agent?(user)
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
end
def raise_already_accepted!(agent)
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
end
# Existing assignments win — manual reassignment and pre-call assignment
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
def assign_conversation!(user)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
end end
end end
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
'queued' => 'ringing', 'queued' => 'ringing',
'initiated' => 'ringing', 'initiated' => 'ringing',
'ringing' => 'ringing', 'ringing' => 'ringing',
'in-progress' => 'in-progress', 'in-progress' => 'in_progress',
'inprogress' => 'in-progress', 'inprogress' => 'in_progress',
'answered' => 'in-progress', 'answered' => 'in_progress',
'completed' => 'completed', 'completed' => 'completed',
'busy' => 'no-answer', 'busy' => 'no_answer',
'no-answer' => 'no-answer', 'no-answer' => 'no_answer',
'failed' => 'failed', 'failed' => 'failed',
'canceled' => 'failed' 'canceled' => 'failed'
}.freeze }.freeze
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
normalized_status = normalize_status(call_status) normalized_status = normalize_status(call_status)
return if normalized_status.blank? return if normalized_status.blank?
conversation = account.conversations.find_by(identifier: call_sid) call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
return unless conversation return unless call
Voice::CallStatus::Manager.new( Voice::CallStatus::Manager.new(call: call).process_status_update(
conversation: conversation,
call_sid: call_sid
).process_status_update(
normalized_status, normalized_status,
duration: payload_duration, duration: payload_duration,
timestamp: payload_timestamp timestamp: payload_timestamp
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
def message
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
end
def http_status
409
end
end
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Integrations::Slack::EmojiFormatter
def self.format(text)
return text if text.blank?
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
short_code = Regexp.last_match(1)
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
# rubocop:disable Rails/DynamicFindBy
emoji = Emoji.find_by_alias(short_code)
# rubocop:enable Rails/DynamicFindBy
emoji ? emoji.raw : match
end
end
end
@@ -35,7 +35,7 @@ module Integrations::Slack::SlackMessageHelper
message_type: :outgoing, message_type: :outgoing,
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''), content: formatted_message_content,
external_source_id_slack: params[:event][:ts], external_source_id_slack: params[:event][:ts],
private: private_note?, private: private_note?,
sender: resolved_sender, sender: resolved_sender,
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
[nil, nil, nil] [nil, nil, nil]
end end
def formatted_message_content
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
Integrations::Slack::EmojiFormatter.format(text)
end
def private_note? def private_note?
params[:event][:text].strip.downcase.starts_with?('note:', 'private:') params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
end end
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox } let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) } let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
let(:admin) { create(:user, :administrator, account: account) } let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end end
context 'when authenticated agent with inbox access' do context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) } before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'creates conference and sets identifier' do it 'resolves the Call by call_sid and invokes the conference service' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference", post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' } params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
body = response.parsed_body body = response.parsed_body
expect(body['conference_sid']).to be_present expect(body['conference_sid']).to eq('CF123')
conversation.reload expect(body['id']).to eq(conversation.display_id)
expect(conversation.identifier).to eq('CALL123')
expect(conference_service).to have_received(:ensure_conference_sid) expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined) expect(conference_service).to have_received(:mark_agent_joined)
end end
it 'does not allow accessing conversations from inboxes without access' do it 'rejects the request when call_sid is missing' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference", post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id } params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content) expect(response).to have_http_status(:unprocessable_content)
expect(conference_service).not_to have_received(:ensure_conference_sid)
end
it 'does not allow accessing calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end end
end end
end end
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end end
context 'when authenticated agent with inbox access' do context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) } before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'ends conference and returns success' do it 'ends the conference for the resolved call' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference", delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id } params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id) expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference) expect(conference_service).to have_received(:end_conference)
end end
it 'does not allow ending conferences for conversations from inboxes without access' do it 'does not allow ending conferences for calls from inboxes without access' do
other_inbox = create(:inbox, account: account) other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil) other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference", delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id } params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
@@ -19,15 +19,24 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
let(:to_number) { channel.phone_number } let(:to_number) { channel.phone_number }
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
instance_double(Voice::InboundCallBuilder)
conversation = create(:conversation, account: account, inbox: inbox) conversation = create(:conversation, account: account, inbox: inbox)
contact = conversation.contact
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
call.update!(conference_sid: call.default_conference_sid)
expect(Voice::InboundCallBuilder).to receive(:perform!).with( expect(Voice::InboundCallBuilder).to receive(:perform!).with(
account: account, account: account,
inbox: inbox, inbox: inbox,
from_number: from_number, from_number: from_number,
call_sid: call_sid call_sid: call_sid
).and_return(conversation) ).and_return(call)
post "/twilio/voice/call/#{digits}", params: { post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid, 'CallSid' => call_sid,
@@ -39,64 +48,59 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>') expect(response.body).to include('<Response>')
expect(response.body).to include('<Dial>') expect(response.body).to include('<Dial>')
expect(response.body).to include(call.conference_sid)
end end
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid) conversation = create(:conversation, account: account, inbox: inbox)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation) call = create(
:call,
expect(Voice::CallSessionSyncService).to receive(:new).with( account: account,
hash_including( inbox: inbox,
conversation: conversation, conversation: conversation,
call_sid: call_sid, contact: conversation.contact,
message_call_sid: conversation.identifier, direction: :outgoing,
leg: { provider_call_id: call_sid
from_number: from_number, )
to_number: to_number, call.update!(conference_sid: call.default_conference_sid)
direction: 'outbound'
}
)
).and_return(sync_double)
post "/twilio/voice/call/#{digits}", params: { post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid, 'CallSid' => call_sid,
'From' => from_number, 'From' => to_number,
'To' => to_number, 'To' => from_number,
'Direction' => 'outbound-api' 'Direction' => 'outbound-api'
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>') expect(response.body).to include(call.conference_sid)
expect(call.reload.parent_call_sid).to be_nil
end end
it 'uses the parent call SID when syncing outbound-dial legs' do it 'records the parent call SID when syncing outbound-dial legs' do
parent_sid = 'CA_parent' parent_sid = 'CA_parent'
child_sid = 'CA_child' child_sid = 'CA_child'
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid) conversation = create(:conversation, account: account, inbox: inbox)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation) call = create(
:call,
expect(Voice::CallSessionSyncService).to receive(:new).with( account: account,
hash_including( inbox: inbox,
conversation: conversation, conversation: conversation,
call_sid: child_sid, contact: conversation.contact,
message_call_sid: parent_sid, direction: :outgoing,
leg: { provider_call_id: parent_sid
from_number: from_number, )
to_number: to_number, call.update!(conference_sid: call.default_conference_sid)
direction: 'outbound'
}
)
).and_return(sync_double)
post "/twilio/voice/call/#{digits}", params: { post "/twilio/voice/call/#{digits}", params: {
'CallSid' => child_sid, 'CallSid' => child_sid,
'ParentCallSid' => parent_sid, 'ParentCallSid' => parent_sid,
'From' => from_number, 'From' => to_number,
'To' => to_number, 'To' => from_number,
'Direction' => 'outbound-dial' 'Direction' => 'outbound-dial'
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(call.reload.parent_call_sid).to eq(parent_sid)
end end
it 'raises not found when inbox is not present' do it 'raises not found when inbox is not present' do
@@ -62,17 +62,17 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
end end
it 'marks the due document as syncing before queueing' do it 'marks the due document as syncing before queueing' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
described_class.new.perform described_class.new.perform
expect(document.reload).to have_attributes( expect(document.reload).to have_attributes(
@@ -7,7 +7,6 @@ RSpec.describe Voice::InboundCallBuilder do
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') } let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' } let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
let(:call_sid) { 'CA1234567890abcdef' } let(:call_sid) { 'CA1234567890abcdef' }
before do before do
@@ -24,98 +23,88 @@ RSpec.describe Voice::InboundCallBuilder do
) )
end end
context 'when no existing conversation matches call_sid' do context 'when no existing call matches call_sid' do
it 'creates a new inbound conversation with ringing status' do it 'creates a new conversation and Call with ringing status' do
conversation = nil call = nil
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1) expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
attrs = conversation.additional_attributes aggregate_failures do
expect(conversation.identifier).to eq(call_sid) expect(call).to be_a(Call)
expect(attrs['call_direction']).to eq('inbound') expect(call.provider_call_id).to eq(call_sid)
expect(attrs['call_status']).to eq('ringing') expect(call.provider).to eq('twilio')
expect(attrs['conference_sid']).to be_present expect(call.direction).to eq('incoming')
expect(attrs.dig('meta', 'initiated_at')).to be_present expect(call.status).to eq('ringing')
expect(conversation.contact.phone_number).to eq(from_number) expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.meta['initiated_at']).to be_present
expect(call.contact.phone_number).to eq(from_number)
end
end end
it 'creates a single voice_call message marked as incoming' do it 'creates a voice_call message matched to the call and linked via message_id' do
conversation = perform_builder call = perform_builder
voice_message = conversation.messages.voice_calls.last voice_message = call.conversation.messages.voice_calls.last
expect(voice_message).to be_present aggregate_failures do
expect(voice_message.message_type).to eq('incoming') expect(voice_message).to be_present
data = voice_message.content_attributes['data'] expect(voice_message.message_type).to eq('incoming')
expect(data).to include( expect(call.message_id).to eq(voice_message.id)
'call_sid' => call_sid, expect(voice_message.call).to eq(call)
'status' => 'ringing', end
'call_direction' => 'inbound',
'conference_sid' => conversation.additional_attributes['conference_sid'],
'from_number' => from_number,
'to_number' => inbox.channel.phone_number
)
expect(data['meta']['created_at']).to be_present
expect(data['meta']['ringing_at']).to be_present
end end
it 'sets the contact name to the phone number for new callers' do it 'sets the contact name to the phone number for new callers' do
conversation = perform_builder call = perform_builder
expect(conversation.contact.name).to eq(from_number) expect(call.contact.name).to eq(from_number)
end end
it 'ensures the conversation has a display_id before building the conference SID' do it 'does not set conversation.identifier or write call state to additional_attributes' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation| call = perform_builder
expect(conversation.display_id).to be_present conversation = call.conversation
original.call(conversation)
end
perform_builder expect(conversation.identifier).to be_nil
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
end end
end end
context 'when a conversation already exists for the call_sid' do context 'when a Call already exists for the call_sid' do
let(:contact) { create(:contact, account: account, phone_number: from_number) } let(:existing_call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: call_sid
)
end
it 'returns the existing call without creating a duplicate' do
existing_call
expect { perform_builder }.not_to change(Call, :count)
expect(perform_builder).to eq(existing_call)
end
end
context 'when the inbox has lock_to_single_conversation enabled' do
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) } let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_conversation) do let!(:existing_open_conversation) do
create( create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
)
end
let(:existing_message) do
create(
:message,
account: account,
inbox: inbox,
conversation: existing_conversation,
message_type: :incoming,
content_type: :voice_call,
sender: contact,
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
)
end end
it 'reuses the conversation without creating a duplicate' do before { inbox.update!(lock_to_single_conversation: true) }
existing_message
expect { perform_builder }.not_to change(account.conversations, :count) it 'reuses the most recent non-resolved conversation' do
existing_conversation.reload call = nil
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound') expect { call = perform_builder }.not_to change(account.conversations, :count)
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing') expect(call.conversation).to eq(existing_open_conversation)
end end
it 'updates the existing voice call message instead of creating a new one' do it 'still creates a new Call and voice_call message on the reused conversation' do
existing_message expect { perform_builder }.to change(Call, :count).by(1)
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count }) .and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
updated_message = existing_conversation.reload.messages.voice_calls.last
data = updated_message.content_attributes['data']
expect(data['status']).to eq('ringing')
expect(data['call_direction']).to eq('inbound')
end end
end end
end end
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}")) .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
allow(inbox).to receive(:channel).and_return(channel) allow(inbox).to receive(:channel).and_return(channel)
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid }) allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
allow(Voice::Conference::Name).to receive(:for).and_call_original
end end
describe '.perform!' do describe '.perform!' do
it 'creates a conversation and voice call message' do it 'creates a conversation, Call, and voice_call message' do
conversation_count = account.conversations.count call = nil
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count expect do
call = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
result = described_class.perform!( aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.direction).to eq('outgoing')
expect(call.status).to eq('ringing')
expect(call.accepted_by_agent_id).to eq(user.id)
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
voice_message = call.conversation.messages.voice_calls.last
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.message_type).to eq('outgoing')
expect(voice_message.call).to eq(call)
end
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = described_class.perform!(
account: account, account: account,
inbox: inbox, inbox: inbox,
user: user, user: user,
contact: contact contact: contact
) )
expect(account.conversations.count).to eq(conversation_count + 1) expect(call.conversation.identifier).to be_nil
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1) expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
conversation = result[:conversation].reload
attrs = conversation.additional_attributes
aggregate_failures do
expect(result[:call_sid]).to eq(call_sid)
expect(conversation.identifier).to eq(call_sid)
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
expect(attrs['agent_id']).to eq(user.id)
expect(attrs['conference_sid']).to be_present
voice_message = conversation.messages.voice_calls.last
expect(voice_message.message_type).to eq('outgoing')
message_data = voice_message.content_attributes['data']
expect(message_data).to include(
'call_sid' => call_sid,
'conference_sid' => attrs['conference_sid'],
'from_number' => channel.phone_number,
'to_number' => contact.phone_number
)
end
end end
it 'raises an error when contact is missing a phone number' do it 'raises an error when contact is missing a phone number' do
@@ -79,19 +79,5 @@ RSpec.describe Voice::OutboundCallBuilder do
) )
end.to raise_error(ArgumentError, 'Agent required') end.to raise_error(ArgumentError, 'Agent required')
end end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end
end end
end end
@@ -4,8 +4,17 @@ describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) } let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:call) do
create(
:call,
account: account,
inbox: channel.inbox,
conversation: conversation,
contact: conversation.contact
)
end
let(:twilio_client) { instance_double(Twilio::REST::Client) } let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation) } let(:service) { described_class.new(call: call) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do before do
@@ -14,42 +23,37 @@ describe Voice::Provider::Twilio::ConferenceService do
end end
describe '#ensure_conference_sid' do describe '#ensure_conference_sid' do
it 'returns existing sid if present' do it 'returns existing sid if present on the Call' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' }) call.update!(conference_sid: 'CF_EXISTING')
expect(service.ensure_conference_sid).to eq('CF_EXISTING') expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end end
it 'sets and returns generated sid when missing' do it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN') expect(service.ensure_conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.reload.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
end end
end end
describe '#mark_agent_joined' do describe '#mark_agent_joined' do
it 'stores agent join metadata' do it 'sets accepted_by_agent on the Call' do
agent = create(:user, account: account) agent = create(:user, account: account)
service.mark_agent_joined(user: agent) service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes expect(call.reload.accepted_by_agent_id).to eq(agent.id)
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
end end
end end
describe '#end_conference' do describe '#end_conference' do
it 'completes in-progress conferences' do it 'completes in-progress conferences matching the call conference_sid' do
call.update!(conference_sid: 'CF123_FRIENDLY')
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList) conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123') conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance) conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy) allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance]) allow(conferences_proxy).to receive(:list).with(friendly_name: 'CF123_FRIENDLY', status: 'in-progress').and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context) allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed') allow(conf_context).to receive(:update).with(status: 'completed')
@@ -57,5 +61,11 @@ describe Voice::Provider::Twilio::ConferenceService do
expect(conf_context).to have_received(:update).with(status: 'completed') expect(conf_context).to have_received(:update).with(status: 'completed')
end end
it 'no-ops when call has no conference_sid' do
allow(twilio_client).to receive(:conferences)
service.end_conference
expect(twilio_client).not_to have_received(:conferences)
end
end end
end end
@@ -4,43 +4,47 @@ require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) } let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) } let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) } let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
let(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do let(:conversation) do
Conversation.create!( create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
account_id: account.id, end
inbox_id: inbox.id, let!(:call) do
contact_id: contact.id, create(
contact_inbox_id: contact_inbox.id, :call,
identifier: call_sid, account: account,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' } inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
) )
end end
let(:message) do let!(:message) do
conversation.messages.create!( msg = conversation.messages.create!(
account_id: account.id, account_id: account.id,
inbox_id: inbox.id, inbox_id: inbox.id,
message_type: :incoming, message_type: :incoming,
sender: contact, sender: contact,
content: 'Voice Call', content: 'Voice Call',
content_type: 'voice_call', content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } } content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'ringing' } }
) )
call.update!(message_id: msg.id)
msg
end end
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new) allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end end
it 'updates conversation and last voice message with call status' do it 'updates the Call and touches the linked message on status transition' do
# Ensure records are created after stub setup previous_updated_at = message.updated_at
conversation travel 1.second
message
described_class.new( described_class.new(
account: account, account: account,
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
call_status: 'completed' call_status: 'completed'
).perform ).perform
conversation.reload call.reload
message.reload message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed') expect(call.status).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed') expect(message.updated_at).to be > previous_updated_at
end end
it 'normalizes busy to no-answer' do it 'normalizes busy to no_answer on the Call' do
conversation
message
described_class.new( described_class.new(
account: account, account: account,
call_sid: call_sid, call_sid: call_sid,
call_status: 'busy' call_status: 'busy'
).perform ).perform
conversation.reload expect(call.reload.status).to eq('no_answer')
message.reload
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
end end
it 'no-ops when conversation not found' do it 'no-ops when no Call matches the provided call_sid' do
expect do expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error end.not_to raise_error
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :call do
association :conversation
account { conversation.account }
inbox { conversation.inbox }
contact { conversation.contact }
provider { :twilio }
direction { :incoming }
status { 'ringing' }
sequence(:provider_call_id) { |n| "CA#{SecureRandom.hex(15)}#{n}" }
end
end
@@ -0,0 +1,20 @@
require 'rails_helper'
describe Integrations::Slack::EmojiFormatter do
describe '.format' do
it 'replaces emoji shortcodes with unicode characters' do
expect(described_class.format('Hello :smile:')).to eq('Hello 😄')
expect(described_class.format('Good job :+1:')).to eq('Good job 👍')
expect(described_class.format('Unknown :unknown_emoji:')).to eq('Unknown :unknown_emoji:')
end
it 'handles nil or empty text' do
expect(described_class.format(nil)).to be_nil
expect(described_class.format('')).to eq('')
end
it 'replaces multiple emojis' do
expect(described_class.format(':smile: :+1:')).to eq('😄 👍')
end
end
end
@@ -165,6 +165,36 @@ describe Messages::MentionService do
end end
end end
context 'when the message sender mentions themselves' do
it 'skips the sender notification while notifying other mentioned users' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://user/#{second_agent.id}/#{second_agent.name})",
private: true,
sender: first_agent
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when mentioned user is not an inbox member' do context 'when mentioned user is not an inbox member' do
let!(:non_member_user) { create(:user, account: account) } let!(:non_member_user) { create(:user, account: account) }
+1 -1
View File
@@ -30,6 +30,6 @@
"playwright": "^1.56.1", "playwright": "^1.56.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.59.1", "typescript-eslint": "^8.59.1",
"uuid": "^13.0.0" "uuid": "^14.0.0"
} }
} }
+5 -5
View File
@@ -57,8 +57,8 @@ importers:
specifier: ^8.59.1 specifier: ^8.59.1
version: 8.59.1(eslint@9.39.4)(typescript@6.0.3) version: 8.59.1(eslint@9.39.4)(typescript@6.0.3)
uuid: uuid:
specifier: ^13.0.0 specifier: ^14.0.0
version: 13.0.0 version: 14.0.0
packages: packages:
@@ -645,8 +645,8 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuid@13.0.0: uuid@14.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true hasBin: true
which@2.0.2: which@2.0.2:
@@ -1234,7 +1234,7 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
uuid@13.0.0: {} uuid@14.0.0: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies: