Compare commits

...
Author SHA1 Message Date
PranavandGitHub 2921db5bd4 Merge branch 'develop' into dedicated-home-for-agents-admin 2026-04-29 16:10:40 -07:00
Pranav 3d7567bf23 feat: Add a dedicated home for agents and admins 2026-04-29 16:05:27 -07:00
TonyandGitHub cd9c8e3303 fix: skip self-mention notification in private notes (#14318)
When an agent mentions themselves in a private note, they no longer
receive a redundant notification for their own mention.

Closes: #4096

# Pull Request Template

## Description

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

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

---------

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


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

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

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

---

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

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

</details>

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


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

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

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

---

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

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

</details>

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

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

Closes the CI failure introduced by #13943.

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

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

---------

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


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




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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-29 17:18:38 +04:00
Aakash BakhleandGitHub c09206c22e fix: flaky time based document sync spec (#14320)
fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/111572/workflows/210e3618-301a-4a8f-a411-826214965441/jobs/146263
2026-04-29 17:36:14 +05:30
Aakash BakhleandGitHub 568aae875b feat: wire up auto-sync job backend [AI-150] (#14117)
# Pull Request Template

## Description

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

Fixes # (issue)

## Type of change

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

## How Has This Been Tested?

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

specs and locally

## Checklist:

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

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

**How to test**

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

---------

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

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-28 21:57:30 +04:00
125 changed files with 4043 additions and 619 deletions
+1
View File
@@ -22,6 +22,7 @@ gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+5 -3
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -349,6 +349,7 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -676,7 +677,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (7.0.5)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
@@ -1075,6 +1076,7 @@ DEPENDENCIES
fcm
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
+8 -1
View File
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -0,0 +1,9 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -0,0 +1,20 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
+3
View File
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -27,6 +28,7 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
StatusBanner,
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
@@ -137,6 +139,7 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
(inboxesList.value || []).filter(isVoiceCallEnabled)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -30,6 +28,7 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
);
});
@@ -111,7 +108,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && !props.voiceEnabled) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -442,6 +444,7 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
slate:
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
amber:
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
};
return classMap[props.color];
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -4,6 +4,7 @@ import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
@@ -40,6 +41,16 @@ const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const isHomeDashboardEnabled = computed(
() => !!uiSettings.value?.home_dashboard_enabled
);
const unreadInboxCount = useMapGetter('notifications/getUnreadCount');
const unreadInboxCountLabel = computed(() => {
const count = unreadInboxCount.value;
if (!count) return '';
return count < 100 ? String(count) : '99+';
});
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
@@ -212,16 +223,28 @@ const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
...(isHomeDashboardEnabled.value
? [
{
name: 'Home',
label: t('SIDEBAR.HOME'),
icon: 'i-lucide-gauge',
to: accountScopedRoute('home_dashboard'),
activeOn: ['home_dashboard', 'home_dashboard_admin'],
},
]
: [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
]),
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
@@ -858,6 +881,20 @@ const menuItems = computed(() => {
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<RouterLink
v-if="isHomeDashboardEnabled"
:to="accountScopedRoute('inbox_view')"
:title="t('SIDEBAR.INBOX')"
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative text-n-slate-11"
>
<span class="i-lucide-bell size-4" />
<span
v-if="unreadInboxCountLabel"
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
>
{{ unreadInboxCountLabel }}
</span>
</RouterLink>
</div>
</section>
<!-- Resize Handle (desktop only) -->
@@ -0,0 +1,63 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const BANNER_COLOR_MAP = {
info: 'blue',
warning: 'amber',
error: 'ruby',
};
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const dismissedBannerIds = ref(
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
);
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
const visibleBanners = computed(() => {
const banners = globalConfig.value?.activePlatformBanners || [];
return banners.filter(
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
);
});
const formattedMessage = message =>
new MessageFormatter(message).formattedMessage;
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
const dismissBanner = banner => {
const key = dismissKey(banner);
if (dismissedBannerIds.value.includes(key)) return;
dismissedBannerIds.value.push(key);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
dismissedBannerIds.value
);
};
</script>
<template>
<Banner
v-for="banner in visibleBanners"
:key="banner.id"
:color="bannerColor(banner.banner_type)"
:action-label="t('GENERAL_SETTINGS.DISMISS')"
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
@action="dismissBanner(banner)"
>
<span
v-dompurify-html="formattedMessage(banner.banner_message)"
class="text-xs"
/>
</Banner>
</template>
@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -51,6 +51,7 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -66,6 +67,7 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -744,6 +753,9 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -362,19 +364,33 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
onKeydown(view, event) {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -47,7 +47,11 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -211,11 +215,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
// Test Voice (Twilio with voice_enabled)
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
expect(wrapper.vm.voiceCallEnabled).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -274,7 +278,8 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
expectedProperties.forEach(prop => {
@@ -1,7 +1,11 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
return {
inbox,
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
voiceCallEnabled,
voiceCallProvider,
};
};
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
@@ -1,5 +1,6 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
+1
View File
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -2,6 +2,7 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
}
};
/**
* Collapse the current selection to a cursor near its head. Used to override
* the default Escape -> selectParentNode behavior which would otherwise keep
* the text highlight visible.
*
* @param {EditorView} view - The ProseMirror EditorView
*/
export const collapseSelection = view => {
const { tr, selection } = view.state;
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
+21 -10
View File
@@ -11,9 +11,29 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -16,6 +16,7 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -1,7 +1,7 @@
{
"INBOX": {
"LIST": {
"TITLE": "My Inbox",
"TITLE": "Notifications",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"404": "There are no active notifications in this group.",
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -58,6 +58,10 @@
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
},
"HOME_DASHBOARD": {
"TITLE": "Home dashboard",
"NOTE": "Show a Home dashboard with team workload, KPIs, and a Captain prompt as your landing page. Hides the My Inbox sidebar item."
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -228,6 +232,128 @@
}
}
},
"HOME_DASHBOARD_PAGE": {
"ROLE_SWITCH": {
"AGENT": "Agent",
"ADMIN": "Admin"
},
"GREETING": {
"MORNING": "Good morning",
"AFTERNOON": "Good afternoon",
"EVENING": "Good evening",
"LATE": "Working late",
"FALLBACK_NAME": "there",
"GREETING_LINE": "{greeting}, ",
"NAME_LINE": "{name}."
},
"TEAM_SUMMARY": {
"PREFIX": "Your team has ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"NEARING_SLA": "{count} nearing SLA",
"AND": ", and ",
"AGENTS_ONLINE": "{count} agents online",
"PERIOD": "."
},
"AGENT_SUMMARY": {
"PREFIX": "You have ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"UNATTENDED": "{count} unattended",
"AND": ", and ",
"NEARING_SLA": "{count} close to SLA breach",
"PERIOD": "."
},
"KPI": {
"OPEN_CONVERSATIONS": "Open conversations",
"AVG_FIRST_RESPONSE": "Avg first response",
"FIRST_RESPONSE": "First response",
"RESOLVED_TODAY": "Resolved today",
"NOTIFICATIONS": "Notifications",
"SLA_BREACHES_TODAY": "SLA breaches today",
"TEAM_CSAT_TODAY": "Team CSAT today",
"SEE_ALL": "See all"
},
"AI": {
"SHORTCUT": "⌘K"
},
"TEAM_WORKLOAD": {
"TITLE": "Team workload",
"SORT_BY": "Sort by",
"HEADERS": {
"AGENT": "Agent",
"WORKLOAD": "Workload",
"UNATTENDED_OPEN": "Unattended / Open",
"FRT": "FRT",
"CSAT": "CSAT"
},
"OFF_SHIFT": "Off shift",
"CAPACITY": "{percent}% capacity",
"YOU": "YOU"
},
"ATTENTION": {
"TITLE": "Needs your attention",
"SUBTITLE": "3 items, surfaced by Captain"
},
"SUGGESTIONS_ADMIN": {
"OVERLOADED": "Who's overloaded right now?",
"HIGH_VOLUME": "What's driving the {count} open conversations today?",
"CSAT_OUTLIERS": "Show CSAT outliers this week",
"TEAM_PERFORMANCE": "Summarize team performance today",
"COACH_AGENT": "Draft a coaching note for {name}",
"QUIET_DAY": "What did the team accomplish today?",
"FIND_PATTERNS": "Trending customer questions"
},
"SUGGESTIONS_AGENT": {
"SUMMARIZE_QUEUE": "Summarize my queue",
"TRIAGE_UNATTENDED": "Help me triage my {count} unattended",
"NEAR_SLA": "Which of my conversations are close to SLA breach?",
"URGENT": "What's most urgent right now?",
"SUMMARIZE_CONVERSATION": "Summarize my conversation with {name}",
"CATCH_UP": "Catch me up on what I missed",
"SLOW_DAY": "What can I work on?"
},
"AI_PLACEHOLDERS_ADMIN": {
"DEFAULT": "Ask Captain about your team…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"AI_PLACEHOLDERS_AGENT": {
"DEFAULT": "Ask anything about your queue…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"SORT_OPTIONS_ADMIN": {
"CAPACITY": "Capacity",
"FIRST_RESPONSE": "First response",
"CSAT_TODAY": "CSAT today",
"RESOLVED_TODAY": "Resolved today"
},
"SORT_OPTIONS_AGENT": {
"PRIORITY": "Priority",
"SLA_BREACH": "SLA breach",
"LATEST": "Latest activity",
"OLDEST": "Oldest first",
"CUSTOMER": "Customer name"
},
"TOP_CONVERSATIONS": {
"TITLE": "Top conversations",
"SLA_LEFT": "{time} left",
"EMPTY": "Nothing in your queue.",
"LOADING": "Loading…"
},
"COPILOT_THREAD": {
"BACK": "Back to home",
"SEND": "Send"
},
"RECENT_THREADS": {
"TITLE": "Recent threads"
}
},
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch account",
@@ -5,6 +5,7 @@ import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { routes as homeRoutes } from './home/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
@@ -21,6 +22,7 @@ export default {
component: AppContainer,
children: [
...captainRoutes,
...homeRoutes,
...inboxRoutes,
...conversation.routes,
...settings.routes,
@@ -0,0 +1,192 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import CopilotAgentMessage from 'dashboard/components-next/copilot/CopilotAgentMessage.vue';
import CopilotAssistantMessage from 'dashboard/components-next/copilot/CopilotAssistantMessage.vue';
import CopilotThinkingGroup from 'dashboard/components-next/copilot/CopilotThinkingGroup.vue';
import CopilotLoader from 'dashboard/components-next/copilot/CopilotLoader.vue';
import CopilotInput from 'dashboard/components-next/copilot/CopilotInput.vue';
import ToggleCopilotAssistant from 'dashboard/components-next/copilot/ToggleCopilotAssistant.vue';
const props = defineProps({
threadId: {
type: [String, Number],
required: true,
},
});
const store = useStore();
const router = useRouter();
const { uiSettings, updateUISettings } = useUISettings();
const { accountScopedRoute } = useAccount();
const assistants = useMapGetter('captainAssistants/getRecords');
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](props.threadId)
);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
if (preferredId) {
const found = assistants.value.find(a => a.id === preferredId);
if (found) return found;
}
return assistants.value[0];
});
const setAssistant = async assistant => {
await updateUISettings({
preferred_captain_assistant_id: assistant.id,
});
};
const sendMessage = async message => {
try {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value?.id,
threadId: props.threadId,
message,
});
} catch (error) {
useAlert(error.message);
}
};
const goHome = () => {
router.push(accountScopedRoute('home_dashboard'));
};
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
messages.value.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
const last = groupedMessages.value[groupedMessages.value.length - 1];
return last?.message_type === 'assistant';
});
const chatContainer = ref(null);
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
watch(() => messages.value, scrollToBottom, { deep: true });
onMounted(async () => {
store.dispatch('captainAssistants/get');
try {
const { data } = await CopilotMessagesAPI.get(props.threadId);
const payload = data?.payload || [];
payload.forEach(message => {
store.dispatch('copilotMessages/upsert', message);
});
scrollToBottom();
} catch {
// best-effort fetch; rely on websocket if API errors
}
});
</script>
<template>
<section class="flex flex-col w-full h-full bg-n-surface-1 text-n-slate-12">
<div
class="flex items-center gap-2 px-6 py-3 border-b border-n-weak flex-shrink-0"
>
<button
type="button"
class="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md text-[13px] font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition-colors bg-transparent border-0 cursor-pointer"
@click="goHome"
>
<span class="i-lucide-chevron-left size-4" />
{{ $t('HOME_DASHBOARD_PAGE.COPILOT_THREAD.BACK') }}
</button>
</div>
<div
ref="chatContainer"
class="flex-1 min-h-0 w-full overflow-y-auto text-sm leading-6 tracking-tight"
>
<div class="w-full max-w-[820px] mx-auto px-6 py-6">
<div
v-if="groupedMessages.length"
class="space-y-6 flex flex-col w-full"
>
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
conversation-inbox-type=""
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<div
v-else
class="flex items-center justify-center h-full text-sm text-n-slate-10"
>
{{ $t('HOME_DASHBOARD_PAGE.TOP_CONVERSATIONS.LOADING') }}
</div>
</div>
</div>
<div class="w-full flex-shrink-0">
<div class="w-full max-w-[820px] mx-auto px-6 pt-px pb-4">
<div class="flex items-center justify-between gap-2 mb-1">
<ToggleCopilotAssistant
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant || {}"
@set-assistant="setAssistant"
/>
<div v-else />
</div>
<CopilotInput
v-if="assistants.length"
class="w-full"
@send="sendMessage"
/>
</div>
</div>
</section>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ROLES } from 'dashboard/constants/permissions.js';
import HomeDashboard from './HomeDashboard.vue';
import HomeCopilotThread from './HomeCopilotThread.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/home'),
name: 'home_dashboard',
component: HomeDashboard,
meta: {
permissions: ROLES,
},
},
{
path: frontendURL('accounts/:accountId/home/admin'),
name: 'home_dashboard_admin',
component: HomeDashboard,
meta: {
permissions: ['administrator'],
},
},
{
path: frontendURL('accounts/:accountId/home/copilot/:threadId'),
name: 'home_copilot_thread',
component: HomeCopilotThread,
props: true,
meta: {
permissions: ROLES,
},
},
];
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -46,6 +47,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -169,19 +171,17 @@ export default {
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,7 +197,6 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -232,6 +231,24 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -812,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -30,7 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -0,0 +1,156 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -13,6 +13,7 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
@@ -43,13 +44,16 @@ export default {
AccessToken,
MfaSettingsCard,
BaseSettingsHeader,
SettingsToggleSection,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { uiSettings, isEditorHotKeyEnabled, updateUISettings } =
useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
uiSettings,
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
@@ -103,6 +107,14 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
homeDashboardEnabled: {
get() {
return !!this.uiSettings?.home_dashboard_enabled;
},
set(value) {
this.updateUISettings({ home_dashboard_enabled: value });
},
},
},
mounted() {
if (this.currentUserId) {
@@ -248,6 +260,16 @@ export default {
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
<SettingsToggleSection
v-model="homeDashboardEnabled"
class="w-full"
:header="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.TITLE')
"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.NOTE')
"
/>
</div>
</SectionLayout>
<SectionLayout
@@ -132,8 +132,10 @@ const headers = computed(() => [
</template>
</BaseTable>
<div class="flex items-center justify-between mt-4">
<p class="text-body-main text-n-slate-11">
<div
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
>
<p class="text-body-main text-n-slate-11 mb-0">
{{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
+3 -1
View File
@@ -39,7 +39,9 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
const homeDashboardEnabled = !!user?.ui_settings?.home_dashboard_enabled;
const landingTarget = homeDashboardEnabled ? 'home' : 'dashboard';
const target = needsOnboarding ? 'onboarding' : landingTarget;
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js';
/**
* Gets a value from a conversation based on the attribute key
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
// For array values like labels, match if any filter value is present.
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
return filterValue.some(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
// a conversation matches if ANY of the filter labels is on it.
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
const conversation = { labels: ['support'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
const conversation = { labels: ['new'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
@@ -24,6 +24,7 @@ const {
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv,
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
} = window.globalConfig || {};
const state = {
@@ -49,6 +50,7 @@ const state = {
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
activePlatformBanners: activePlatformBanners || [],
};
export const getters = {
+7 -2
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_secret :string
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
@@ -11,6 +12,8 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -58,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
end
end
private
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
end
end
end
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
+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)
validated_mentioned_ids.each do |user_id|
next if self_mention?(user_id)
NotificationBuilder.new(
notification_type: 'conversation_mention',
user: User.find(user_id),
@@ -60,6 +62,10 @@ class Messages::MentionService
end
end
def self_mention?(user_id)
message.sender_type == 'User' && user_id.to_i == message.sender_id
end
def add_mentioned_users_as_participants(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id|
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
@@ -0,0 +1,66 @@
class Whatsapp::LiquidTemplateProcessorService
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
module JsonEscapeFilter
def json_escape(input)
input.to_s.to_json[1..-2]
end
end
pattr_initialize [:campaign!, :contact!]
def process_template_params(template_params)
return template_params if template_params.blank?
template_params_copy = template_params.deep_dup
processed_params = template_params_copy['processed_params']
return template_params_copy if processed_params.blank?
rendered_params = render_liquid(processed_params)
return nil if blank_render?(processed_params, rendered_params)
template_params_copy.merge('processed_params' => rendered_params)
end
private
def render_liquid(processed_params)
raw = processed_params.to_json
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
JSON.parse(rendered)
rescue Liquid::Error, JSON::ParserError
processed_params
end
def drops
{
'contact' => ContactDrop.new(contact),
'agent' => UserDrop.new(campaign.sender),
'inbox' => InboxDrop.new(campaign.inbox),
'account' => AccountDrop.new(campaign.account)
}
end
def blank_render?(original, rendered)
case original
when Hash then blank_render_in_hash?(original, rendered)
when Array then blank_render_in_array?(original, rendered)
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
else false
end
end
def blank_render_in_hash?(original, rendered)
return false unless rendered.is_a?(Hash)
original.any? { |key, value| blank_render?(value, rendered[key]) }
end
def blank_render_in_array?(original, rendered)
return false unless rendered.is_a?(Array)
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
end
end
@@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService
return
end
send_whatsapp_template_message(to: contact.phone_number)
processed_template_params = process_liquid_template_params(contact)
return if processed_template_params.nil?
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
end
def process_audience(audience_labels)
@@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
def process_liquid_template_params(contact)
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
processed_template_params
rescue StandardError => e
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
nil
end
def send_whatsapp_template_message(to:, template_params:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
template_params: template_params
)
name, namespace, lang_code, processed_parameters = processor.call
+10 -4
View File
@@ -72,6 +72,7 @@ if resource.twilio?
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end
end
@@ -131,8 +132,13 @@ if resource.whatsapp?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
@@ -174,6 +174,10 @@
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-megaphone-line" viewBox="0 0 20 20">
<path d="M15.8333 3.33334C16.0543 3.33334 16.2663 3.42114 16.4226 3.57742C16.5789 3.7337 16.6667 3.94566 16.6667 4.16668V15.8333C16.6667 16.0544 16.5789 16.2663 16.4226 16.4226C16.2663 16.5789 16.0543 16.6667 15.8333 16.6667C14.3967 16.6667 12.9408 16.2917 11.6575 15.5267C10.5833 14.8842 9.6675 14.0317 8.9575 13.0217L8.33333 14.8933C8.26541 15.0989 8.12416 15.2722 7.93666 15.3803C7.74917 15.4884 7.52804 15.5239 7.31583 15.48L5.67083 15.0392C5.45946 14.9842 5.27514 14.8558 5.15098 14.677C5.02683 14.4982 4.97074 14.2812 4.99249 14.065L5.5375 10.8333H4.16667C3.94565 10.8333 3.72369 10.7455 3.56741 10.5893C3.41113 10.433 3.32333 10.221 3.32333 10V7.50001C3.32333 7.27899 3.41113 7.06703 3.56741 6.91075C3.72369 6.75447 3.94565 6.66668 4.16667 6.66668H8.0625C9.43167 4.56834 12.2483 3.33334 15.8333 3.33334ZM7.60667 8.33334H5V9.16668H7.29167L7.60667 8.33334ZM15 5.07501C12.4167 5.24168 10.3483 6.23168 9.2675 7.82168L7.97833 10.8975L6.63417 14.0508L7.12 14.1817L7.78917 12.1817L8.335 11.3683C8.335 11.3683 8.37167 11.4092 8.42667 11.4783C9.265 12.5483 10.3342 13.4267 11.565 14.0617C12.5833 14.6275 13.7608 14.935 15 14.99V5.07501Z" fill="currentColor"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

@@ -16,6 +16,7 @@ as defined by the routes in the `admin/` namespace
users: 'icon-user-follow-line',
platform_apps: 'icon-apps-2-line',
agent_bots: 'icon-robot-line',
platform_banners: 'icon-megaphone-line',
}
%>
@@ -33,6 +34,7 @@ as defined by the routes in the `admin/` namespace
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
+3 -3
View File
@@ -191,10 +191,10 @@
- name: assignment_v2
display_name: Assignment V2
enabled: true
- name: twilio_content_templates
display_name: Twilio Content Templates
- name: captain_document_auto_sync
display_name: Captain Document Auto Sync
enabled: false
deprecated: true
premium: true
- name: advanced_search
display_name: Advanced Search
enabled: false
+5
View File
@@ -40,6 +40,11 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Merge enterprise-only cron entries when running an enterprise build.
# Mirrors the conditional-load pattern already used for enterprise initializers.
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
+3
View File
@@ -379,6 +379,9 @@ en:
limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB'
sync_not_supported_for_pdf: 'Sync is not supported for PDF documents'
sync_only_available_documents: 'Sync is only available for processed documents'
sync_already_in_progress: 'Document sync is already in progress'
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+4 -1
View File
@@ -77,7 +77,9 @@ Rails.application.routes.draw do
resources :custom_tools do
post :test, on: :collection
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :documents, only: [:index, :show, :create, :destroy] do
post :sync, on: :member
end
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
@@ -651,6 +653,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member, action: :destroy_avatar
end
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
resources :platform_banners
resource :instance_status, only: [:show]
resource :settings, only: [:show] do
@@ -0,0 +1,7 @@
class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0]
def change
add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false
add_column :channel_twilio_sms, :twiml_app_sid, :string
add_column :channel_twilio_sms, :api_key_secret, :string
end
end
@@ -0,0 +1,20 @@
class DropChannelVoice < ActiveRecord::Migration[7.0]
def up
drop_table :channel_voice, if_exists: true
end
def down
create_table :channel_voice do |t|
t.string :phone_number, null: false
t.string :provider, null: false, default: 'twilio'
t.jsonb :provider_config, null: false
t.integer :account_id, null: false
t.jsonb :additional_attributes, default: {}
t.timestamps
end
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
@@ -0,0 +1,11 @@
class CreatePlatformBanners < ActiveRecord::Migration[7.1]
def change
create_table :platform_banners do |t|
t.text :banner_message, null: false
t.integer :banner_type, null: false, default: 0
t.boolean :active, default: true, null: false
t.timestamps
end
end
end
@@ -0,0 +1,22 @@
class RepurposeTwilioContentTemplatesFlagForCaptainDocumentAutoSync < ActiveRecord::Migration[7.1]
def up
# The twilio_content_templates flag (deprecated) has been renamed to captain_document_auto_sync.
# Disable it on any accounts that had twilio_content_templates enabled so the repurposed
# flag starts in its intended default-off state.
Account.feature_captain_document_auto_sync.find_each(batch_size: 100) do |account|
account.disable_features(:captain_document_auto_sync)
account.save!(validate: false)
end
# Remove the stale twilio_content_templates entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
# Leaving it would cause NoMethodError in enable_default_features when
# creating new accounts (feature_twilio_content_templates= no longer exists).
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |f| f['name'] == 'twilio_content_templates' }
config.save!
GlobalConfig.clear_cache
end
end
@@ -0,0 +1,18 @@
class BackfillCaptainDocumentSyncMetadata < ActiveRecord::Migration[7.0]
def up
return unless ChatwootApp.enterprise?
# rubocop:disable Rails/SkipsModelValidations
Captain::Document
.syncable
.where(status: :available, sync_status: nil, last_synced_at: nil)
.in_batches(of: 1000) do |batch|
batch.update_all('sync_status = 1, last_synced_at = updated_at')
end
# rubocop:enable Rails/SkipsModelValidations
end
def down
# No-op. This is a one-time baseline for existing available web documents.
end
end
+58 -46
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -52,6 +52,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.boolean "auto_offline", default: true, null: false
t.bigint "custom_role_id"
t.bigint "agent_capacity_policy_id"
t.integer "status", default: 0, null: false
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
t.index ["account_id"], name: "index_account_users_on_account_id"
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
@@ -71,6 +72,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0
t.integer "contactable_contacts_count", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status"
@@ -381,11 +383,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
end
create_table "captain_inboxes", force: :cascade do |t|
@@ -552,6 +554,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "api_key_sid"
t.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated"
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -568,18 +573,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -620,13 +613,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contacts_count"
t.integer "contacts_count", default: 0, null: false
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
t.text "source_id", null: false
@@ -634,14 +627,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false
t.string "pubsub_token"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
end
create_table "contacts", id: :serial, force: :cascade do |t|
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
t.string "name", default: ""
t.string "email"
t.string "phone_number"
@@ -652,25 +645,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "identifier"
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.integer "contact_type", default: 0
t.boolean "resolved", default: false, null: false
t.integer "contact_type", default: 0, null: false
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
t.index ["account_id"], name: "contacts2_account_id_idx"
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
end
create_table "conversation_participants", force: :cascade do |t|
@@ -698,7 +693,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.datetime "agent_last_seen_at", precision: nil
t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.string "identifier"
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id"
@@ -712,6 +707,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "last_message_id"
t.bigint "last_incoming_message_id"
t.bigint "last_non_activity_message_id"
t.text "cached_summary"
t.datetime "cached_summary_at"
t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -723,6 +724,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
@@ -789,7 +793,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end
create_table "custom_filters", force: :cascade do |t|
@@ -972,7 +975,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.integer "visibility", default: 0
t.bigint "created_by_id"
t.bigint "updated_by_id"
t.jsonb "actions", default: {}, null: false
t.jsonb "actions", default: "{}", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_macros_on_account_id"
@@ -991,7 +994,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["user_id"], name: "index_mentions_on_user_id"
end
create_table "messages", id: :serial, force: :cascade do |t|
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
t.text "content"
t.integer "account_id", null: false
t.integer "inbox_id", null: false
@@ -1010,18 +1013,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.jsonb "additional_attributes", default: {}
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
t.index ["account_id"], name: "messages2_account_id_idx"
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
t.index ["created_at"], name: "messages2_created_at_idx"
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
t.index ["source_id"], name: "messages2_source_id_idx"
end
create_table "notes", force: :cascade do |t|
@@ -1096,6 +1099,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.datetime "updated_at", null: false
end
create_table "platform_banners", force: :cascade do |t|
t.text "banner_message", null: false
t.integer "banner_type", default: 0, null: false
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false
t.string "name", null: false
@@ -1145,6 +1156,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.float "value_in_business_hours"
t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil
t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox"
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
t.index ["account_id"], name: "index_reporting_events_on_account_id"
@@ -1278,7 +1290,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false
t.boolean "otp_required_for_login", default: false, null: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
phone_source_id
end
def phone_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
return SecureRandom.uuid if @contact.phone_number.blank?
@@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder
end
def allowed_channels?
super || @inbox.channel_type == 'Channel::Voice'
super || twilio_voice_inbox?
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
end
end
@@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -7,7 +7,10 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
@responses = process_bulk_action
result = process_bulk_action
return if performed?
@responses = result
end
private
@@ -15,13 +18,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def process_bulk_action
@@ -48,13 +51,38 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end
def handle_documents
return [] unless params[:fields][:status] == 'delete'
case params[:fields][:status]
when 'delete'
delete_documents
when 'sync'
sync_documents
else
[]
end
end
def delete_documents
documents = Current.account.captain_documents.where(id: params[:ids])
return [] unless documents.exists?
return render json: { count: 0 } unless documents.exists?
documents.destroy_all
[]
destroyed_documents = documents.destroy_all
render json: { count: destroyed_documents.size }
end
def sync_documents
synced_document_ids = []
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable?
next unless document.available?
next if document.sync_in_progress?
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
synced_document_ids << document.id
end
render json: { ids: synced_document_ids, count: synced_document_ids.size }
end
def permitted_params
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_document, only: [:show, :destroy, :sync]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
@@ -29,6 +29,16 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
def destroy
@document.destroy
head :no_content
@@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
@voice_inbox ||= begin
inbox = Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::TwilioSms'
).find(params.require(:inbox_id))
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
inbox
end
end
end
@@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
super
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
super
end
def create_channel
return create_voice_channel if permitted_params[:channel][:type] == 'voice'
super
end
def get_channel_attributes(channel_type)
attrs = super
attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms'
attrs
end
def create_voice_channel
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice')
voice_params = params.require(:channel).permit(
:phone_number, :provider,
provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret]
)
config = voice_params[:provider_config] || {}
Current.account.twilio_sms.create!(
phone_number: voice_params[:phone_number],
account_sid: config[:account_sid],
auth_token: config[:auth_token],
api_key_sid: config[:api_key_sid],
api_key_secret: config[:api_key_secret],
medium: :sms,
voice_enabled: true
)
end
end
@@ -169,8 +169,10 @@ class Twilio::VoiceController < ApplicationController
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
phone_number = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
@inbox = channel.inbox
end
@@ -1,6 +1,9 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
# A single page fetch + fingerprint compare should complete in seconds.
# 10 minutes is generous headroom — if still "syncing" after that, the worker likely died mid-run.
# Shared with ScheduleSyncsJob so stale locks are re-enqueued at the same threshold.
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
@@ -78,6 +81,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
@@ -0,0 +1,65 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
PER_ACCOUNT_HOURLY_CAP = 50
GLOBAL_HOURLY_CAP = 1000
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval
next unless interval
stats[:accounts_scheduled] += 1
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
end
log_scheduler_summary(stats)
end
private
def enqueue_due_documents(account, interval)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
enqueued_count = 0
account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)',
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
next unless document.syncable?
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1
enqueued_count += 1
end
enqueued_count
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
global_cap_hit: @remaining_global_capacity <= 0,
remaining_global_capacity: @remaining_global_capacity
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
end
@@ -14,7 +14,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
status: :available,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
@@ -1,6 +1,10 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
class PermanentCrawlError < StandardError; end
queue_as :low
discard_on PermanentCrawlError
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
@@ -11,23 +15,71 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success?
persist_document!(document, normalized_link, crawler)
rescue PermanentCrawlError
raise
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
private
def handle_failed_fetch!(document, status_code, page_link)
error_code = http_error_code(status_code)
mark_failed!(document, error_code) if document.persisted?
error_message = "Failed to fetch page: #{page_link}"
raise PermanentCrawlError, error_message if permanent_failure?(error_code)
raise error_message
end
def persist_document!(document, normalized_link, crawler)
document.update!(
external_link: normalized_link,
name: (crawler.page_title || '')[0..254],
content: (crawler.body_markdown || '')[0..14_999],
status: :available,
**synced_attributes
)
end
def synced_attributes
{
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
}
end
def mark_failed!(document, error_code)
document.update!(
status: :available,
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
def permanent_failure?(error_code)
Captain::Documents::SyncService::PERMANENT_ERROR_CODES.include?(error_code)
end
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
+16 -29
View File
@@ -26,12 +26,14 @@
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
SYNC_STALE_TIMEOUT = 2.hours
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
@@ -59,6 +61,7 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -75,36 +78,8 @@ class Captain::Document < ApplicationRecord
pdf_file.blob.byte_size if pdf_file.attached?
end
def content_fingerprint
metadata&.dig('content_fingerprint')
end
def content_fingerprint=(value)
self.metadata = (metadata || {}).merge('content_fingerprint' => value)
end
def last_sync_error_code
metadata&.dig('last_sync_error_code')
end
def last_sync_error_code=(value)
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
update!(openai_file_id: file_id)
end
def display_url
@@ -121,6 +96,18 @@ class Captain::Document < ApplicationRecord
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
def syncable?
!pdf_document?
end
def sync_stale?
sync_syncing? && (last_sync_attempted_at.blank? || last_sync_attempted_at < SYNC_STALE_TIMEOUT.ago)
end
def sync_in_progress?
sync_syncing? && !sync_stale?
end
private
def enqueue_crawl_job
-122
View File
@@ -1,122 +0,0 @@
# == Schema Information
#
# Table name: channel_voice
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# phone_number :string not null
# provider :string default("twilio"), not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_voice_on_account_id (account_id)
# index_channel_voice_on_phone_number (phone_number) UNIQUE
#
class Channel::Voice < ApplicationRecord
include Channelable
self.table_name = 'channel_voice'
validates :phone_number, presence: true, uniqueness: true
validates :provider, presence: true
validates :provider_config, presence: true
# Validate phone number format (E.164 format)
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
# Provider-specific configs stored in JSON
validate :validate_provider_config
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
def name
"Voice (#{phone_number})"
end
def messaging_window_enabled?
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
end
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
def twilio?
provider == 'twilio'
end
def validate_provider_config
return if provider_config.blank?
case provider
when 'twilio'
validate_twilio_config
end
end
def validate_twilio_config
config = provider_config.with_indifferent_access
# Require credentials and provisioned TwiML App SID
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
def provider_config_hash
if provider_config.is_a?(Hash)
provider_config
else
JSON.parse(provider_config.to_s)
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
app_sid = service.perform
return if app_sid.blank?
cfg = provider_config.with_indifferent_access
cfg[:twiml_app_sid] = app_sid
self.provider_config = cfg
rescue StandardError => e
error_details = {
error_class: e.class.to_s,
message: e.message,
phone_number: phone_number,
account_id: account_id,
backtrace: e.backtrace&.first(5)
}
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
public :provider_config_hash
end
@@ -1,4 +1,11 @@
module Enterprise::Account
CAPTAIN_SYNC_INTERVALS = {
'hacker' => nil,
'startups' => 7.days,
'business' => 1.day,
'enterprise' => 6.hours
}.freeze
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
@@ -34,6 +41,14 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def captain_document_sync_interval
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
CAPTAIN_SYNC_INTERVALS[plan.downcase]
end
def saml_enabled?
saml_settings&.saml_enabled? || false
end
@@ -0,0 +1,81 @@
module Enterprise::Channel::TwilioSms
extend ActiveSupport::Concern
def self.prepended(base)
base.class_eval do
encrypts :api_key_secret if Chatwoot.encryption_configured?
validate :voice_requires_phone_number, if: :voice_enabled?
before_validation :provision_twiml_app, on: :create, if: :voice_enabled?
before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true?
after_commit :teardown_voice, on: :update, if: :voice_disabled?
end
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
end
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
# Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super.
def client
if api_key_sid.present? && api_key_secret.present?
Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid)
else
super
end
end
private
def voice_requires_phone_number
return if phone_number.present?
errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID')
end
def voice_enabled_changed_to_true?
voice_enabled? && voice_enabled_changed?
end
def voice_disabled?
!voice_enabled? && voice_enabled_previously_changed?
end
def teardown_voice
Twilio::VoiceTeardownService.new(channel: self).perform
end
def provision_twiml_app
return if twiml_app_sid.present?
return if phone_number.blank?
validate_voice_capability!
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
self.twiml_app_sid = service.perform
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}")
errors.add(:base, "Twilio voice setup failed: #{e.message}")
end
def validate_voice_capability!
number = client.incoming_phone_numbers.list(phone_number: phone_number).first
raise 'Phone number not found in Twilio account' unless number
raise 'This phone number does not support voice calls' unless number.capabilities['voice']
end
alias provision_twiml_app_on_update provision_twiml_app
end
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_many :calls, dependent: :destroy_async
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -23,6 +23,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def sync?
@account_user.administrator?
end
def playground?
true
end
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
private
def firecrawl_configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
Captain::Tools::FirecrawlService.configured?
end
def fetch_with_firecrawl
@@ -52,14 +52,13 @@ class Captain::Documents::SinglePageFetcher
end
def fetch_with_fallback
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
crawler = Captain::Tools::SimplePageCrawlService.new(@url)
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
end
def perform
@document.store_sync_step('fetching')
@document.update!(sync_step: 'fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
@@ -20,17 +20,21 @@ class Captain::Documents::SyncService
raise_for_error_code(result.error_code)
end
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
previous_fingerprint = @document.content_fingerprint
if fingerprint == @document.content_fingerprint
if new_fingerprint == previous_fingerprint
mark_synced
return :unchanged
end
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
@document.update!(sync_step: 'updating')
update_content(result, new_fingerprint)
# Without a prior fingerprint we cannot tell a first-ever sync apart from a real
# change, so treat it as unchanged to keep downstream signals quiet on baseline.
previous_fingerprint.present? ? :updated : :unchanged
end
private
@@ -42,6 +46,7 @@ class Captain::Documents::SyncService
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
@@ -50,6 +55,7 @@ class Captain::Documents::SyncService
def mark_synced
@document.update!(
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -62,6 +68,7 @@ class Captain::Documents::SyncService
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -1,5 +1,6 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -30,7 +31,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def fetch_website_content
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
text_content = crawler.body_text_content
text_content = crawler.body_markdown
page_title = crawler.page_title
meta_description = crawler.meta_description
@@ -2,9 +2,14 @@ class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
.present?
end
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
raise 'Missing API key' if @api_key.blank?
end
def perform(url, webhook_url, crawl_limit = 10)
@@ -1,12 +1,16 @@
class Captain::Tools::SimplePageCrawlService
attr_reader :external_link
attr_reader :external_link, :status_code
def initialize(external_link)
@external_link = external_link
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@parser = Captain::Tools::HtmlPageParser.new(fetch_body)
@doc = @parser.doc
end
def success?
status_code.to_i.between?(200, 299)
end
def page_links
sitemap? ? extract_links_from_sitemap : extract_links_from_html
end
@@ -15,7 +19,7 @@ class Captain::Tools::SimplePageCrawlService
@parser.title
end
def body_text_content
def body_markdown
@parser.body_markdown
end
@@ -35,6 +39,21 @@ class Captain::Tools::SimplePageCrawlService
private
def fetch_body
body = ''
SafeFetch.fetch(external_link, validate_content_type: false) do |result|
body = result.tempfile.read
end
@status_code = 200
body
rescue SafeFetch::HttpError => e
@status_code = e.message.to_i
''
rescue SafeFetch::Error
@status_code = nil
''
end
def sitemap?
@external_link.end_with?('.xml')
end
@@ -1,9 +1,9 @@
module Enterprise::Contacts::ContactableInboxesService
private
# Extend base selection to include Voice inboxes
# Extend base selection to include voice-enabled TwilioSms inboxes
def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
super
end
@@ -0,0 +1,33 @@
class Twilio::VoiceTeardownService
pattr_initialize [:channel!]
def perform
delete_twiml_app if channel.twiml_app_sid.present?
clear_number_webhooks
ensure
clear_voice_credentials
end
private
def delete_twiml_app
channel.client.applications(channel.twiml_app_sid).delete
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_number_webhooks
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
return if numbers.empty?
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(voice_url: '', status_callback: '')
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_voice_credentials
channel.update(twiml_app_sid: nil)
end
end
@@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService
private
def validate_token_credentials!
# Only validate Account SID + Auth Token
token_client.incoming_phone_numbers.list(limit: 1)
channel.client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
@@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService
def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = api_key_client.applications.create(
app = channel.client.applications.create(
friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD
@@ -38,39 +37,25 @@ class Twilio::VoiceWebhookSetupService
end
def configure_number_webhooks!
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
if numbers.empty?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return
end
api_key_client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise
end
def api_key_client
@api_key_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
end
end
def token_client
@token_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
end
end
def log_twilio_error(context, error)
details = build_error_details(context, error)
add_twilio_specific_details(details, error)
@@ -80,11 +65,10 @@ class Twilio::VoiceWebhookSetupService
end
def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{
context: context,
phone_number: channel.phone_number,
account_sid: cfg[:account_sid],
account_sid: channel.account_sid,
error_class: error.class.to_s,
message: error.message
}
@@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token)
end
end
@@ -1,5 +1,5 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
pattr_initialize [:conversation!]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
@@ -19,10 +19,11 @@ class Voice::Provider::Twilio::ConferenceService
end
def end_conference
twilio_client
client = conversation.inbox.channel.client
client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
@@ -31,16 +32,4 @@ class Voice::Provider::Twilio::ConferenceService
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
account_sid: channel.account_sid,
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
phone_number: channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
has_twiml_app: channel.twiml_app_sid.present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
def channel
@channel ||= inbox.channel
end
def identity
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
channel.account_sid,
channel.api_key_sid,
channel.api_key_secret,
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_sid = channel.twiml_app_sid
grant.outgoing_application_params = outgoing_params
end
end
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
accountSid: channel.account_sid,
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
digits = channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
+10
View File
@@ -0,0 +1,10 @@
# Enterprise-only Sidekiq cron schedule.
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
# Add cron entries here when the referenced job class lives under enterprise/.
# Captain document auto-sync scheduler
# Runs hourly, finds due documents based on plan sync intervals
captain_documents_schedule_syncs_job:
cron: '0 * * * *'
class: 'Captain::Documents::ScheduleSyncsJob'
queue: scheduled_jobs
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Integrations::Slack::EmojiFormatter
def self.format(text)
return text if text.blank?
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
short_code = Regexp.last_match(1)
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
# rubocop:disable Rails/DynamicFindBy
emoji = Emoji.find_by_alias(short_code)
# rubocop:enable Rails/DynamicFindBy
emoji ? emoji.raw : match
end
end
end
@@ -35,7 +35,7 @@ module Integrations::Slack::SlackMessageHelper
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
content: formatted_message_content,
external_source_id_slack: params[:event][:ts],
private: private_note?,
sender: resolved_sender,
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
[nil, nil, nil]
end
def formatted_message_content
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
Integrations::Slack::EmojiFormatter.format(text)
end
def private_note?
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
end
@@ -19,7 +19,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
:captain_document,
2,
assistant: assistant,
account: account
account: account,
status: :available
)
end
@@ -115,7 +116,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
end
it 'deletes the documents and returns an empty array' do
it 'deletes the documents and returns the deleted count' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: document_delete_params,
@@ -124,7 +125,118 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end.to change(Captain::Document, :count).by(-2)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
expect(json_response).to eq({ count: documents.size })
end
end
context 'when syncing documents' do
let(:sync_params) do
{
type: 'AssistantDocument',
ids: documents.map(&:id),
fields: { status: 'sync' }
}
end
before { clear_enqueued_jobs }
it 'queues a sync for each web document and returns the enqueued document ids' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
documents.each do |document|
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: documents.map(&:id), count: documents.size })
end
it 'skips PDF documents because they are not syncable' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [pdf_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [in_progress_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'queues stale syncing documents again' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
freeze_time do
syncing_document.update!(
sync_status: :syncing,
last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago
)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document)
expect(syncing_document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end
it 'denies non-administrators' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
@@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant2) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
let(:captain_limits) do
{
:startups => { :documents => 1, :responses => 100 }
@@ -141,6 +141,27 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:name]).to eq(document.name)
expect(json_response[:external_link]).to eq(document.external_link)
end
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('synced')
expect(json_response[:last_synced_at]).to eq(synced_at.to_i)
end
it 'does not report failed documents without a successful sync as last synced' do
document.update!(sync_status: :failed, last_sync_attempted_at: 1.minute.ago)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('failed')
expect(json_response[:last_synced_at]).to be_nil
end
end
end
@@ -235,6 +256,98 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents/:id/sync' do
before { clear_enqueued_jobs }
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'denies the request' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'queues a sync and returns accepted' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'queues stale syncing documents again' do
freeze_time do
document.update!(sync_status: :syncing, last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects PDF documents with an explanatory error' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{pdf_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{in_progress_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do
before do
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) }
@@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
it 'creates a voice inbox when administrator' do
account.enable_features('channel_voice')
account.save!
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
perform: "AP#{SecureRandom.hex(16)}"))
@@ -34,8 +39,7 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
api_key_secret: SecureRandom.hex(16) } } },
as: :json
expect(response).to have_http_status(:success)
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') }
let(:inbox) { channel.inbox }
let(:digits) { channel.phone_number.delete_prefix('+') }

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