Compare commits

..
Author SHA1 Message Date
Shivam Mishra 56d9cc7e0b fix: use UPN for imap_login on Microsoft OAuth callback 2026-05-21 17:41:39 +05:30
Shivam Mishra 092f9ef500 feat: remove prompt param 2026-05-19 15:40:30 +05:30
1d2f3e86dd feat(companies): track company last activity (#14435)
Tracks company recency from linked contact activity so the Companies
list and detail page can show/sort by real customer engagement instead
of generic record updates.

## Closes

None.

## Why

Company recency should reflect activity from people associated with the
company. This keeps the signal tied to persisted contact activity,
without treating passive online presence or widget heartbeat pings as
company activity.

## What Changed

- Adds a company helper to record `last_activity_at` from linked contact
activity.
- Rolls up `Contact#last_activity_at` changes to the associated company.
- Initializes company activity when an already-active contact is
associated with a company, including the business-email auto-association
path.
- Throttles company activity rollups to once every 5 minutes per company
to avoid unnecessary writes during active conversations.
- Treats company activity as monotonic: unlinking, moving, or deleting
contacts does not move a company's activity timestamp backwards.
- Leaves historical backfill, online presence tracking, widget visit
tracking, and richer activity attribution out of scope.

## How to Test

1. Open an account with Companies enabled and visit the Companies list.
2. Trigger activity for a contact that belongs to a company, for example
by receiving or sending a message in that contact's conversation.
3. Confirm the linked company shows a recent activity timestamp in the
Companies list/detail page after the contact activity updates.
4. Associate an already-active contact with a company and confirm the
company receives that contact's existing activity timestamp.
5. Confirm repeated contact activity within a short window does not
continuously rewrite the company timestamp.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-18 15:01:05 +05:30
3253e863ed fix: validate OpenAI hook credentials (#14068)
# Pull Request Template

## Description

- Validates openai key while configuring hooks
- added backfill logic

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.
locally

<img width="1710" height="1234" alt="CleanShot 2026-04-15 at 16 15
02@2x"
src="https://github.com/user-attachments/assets/3d319fe0-19f9-4fd0-9308-74987daac2e1"
/>

<img width="2884" height="1136" alt="CleanShot 2026-05-11 at 19 22
53@2x"
src="https://github.com/user-attachments/assets/5eae8650-985b-4c4a-af42-35f7175ff52d"
/>



## 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

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-18 14:08:57 +05:30
Aakash BakhleandGitHub 059d840272 feat: Refresh llm settings when superadmin configs change [AI-151] (#14388)
# Pull Request Template

## Description

fixes:
https://linear.app/chatwoot/issue/AI-151/captains-super-admin-config-dont-get-applied-into-rails-without

## Type of change

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


## How Has This Been Tested?

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

To test locally: 
go to super admin -> settings -> captain -> Change endpoint to something
incorrect
go to local app -> captain -> playground -> try chatting (should fail
due to incorrect endpoint)

now in super admin captain settings, set the correct endpoint then chat
in playground. Now it should work.

Current develop code doesn't reflect the changes in installation config
for captain instantly, needs a server restart.

## 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-05-18 14:08:26 +05:30
b8deb89613 fix: make SAML callback session independent (#14467)
This PR makes SAML login independent of Rails session cookies

## Problem

The normal SAML login flow should be straightforward:

- User opens Chatwoot.
- Chatwoot creates `_chatwoot_session`.
- User starts SSO.
- Chatwoot redirects the browser to the SAML provider.
- The provider authenticates the user.
- The provider sends the browser back to Chatwoot's ACS URL.
- Chatwoot reads the SAML response, finds or creates the user, and logs
them in.

The fragile step is the ACS callback. Most SSO flows return to the app
through browser redirects where cookies usually pass through as
expected. **ADFS commonly returns the SAML response with a cross-site
POST**. With Chatwoot's session cookie using `SameSite=Lax`, browsers
may not send `_chatwoot_session` on that POST.

SAML validation itself does not need the old Rails session cookie. The
problem was our callback handoff after validation. DeviseTokenAuth
stores the verified OmniAuth payload in Rails session, then redirects to
a second callback route. If the browser does not preserve that session,
Chatwoot has already received a valid SAML response but can no longer
finish login.

## Solution

This PR removes the session-backed handoff for SAML only:

- The SAML callback completes login in the same request where OmniAuth
validates the SAML response.
- Chatwoot reads the verified auth payload directly from
`request.env['omniauth.auth']`.
- Account context and RelayState come from callback params or OmniAuth
env data, not Rails session.
- Other OmniAuth providers continue using the existing DeviseTokenAuth
flow.
- Mobile SAML still works when the IdP returns `RelayState=mobile`; the
callback redirects to the mobile deep link with the generated SSO token.

The previous SAML override used `303 See Other` to avoid replaying the
SAML POST into the second callback route. This change keeps that intent,
but removes the second callback route for SAML entirely.

## Screen recording

### SP Initiated

https://github.com/user-attachments/assets/b0735e93-3864-4cc3-b6fc-419fff4b549e

### IDP Initiated

https://github.com/user-attachments/assets/3ded0246-933c-4c85-9b7c-fa15fdc34883

## Testing

Manual validation:

- Complete a SAML login.
- In the browser network trace, find the IdP POST to
`/omniauth/saml/callback?account_id=<account-id>`.
- Confirm it redirects directly to `/app/login?...sso_auth_token=...`
for web login.
- For mobile, confirm `RelayState=mobile` redirects to the configured
mobile deep link.
- Confirm there is no intermediate `/auth/saml/callback` request.

Testing with mocksaml.com:

- Configure Chatwoot with a public `FRONTEND_URL`.
- Set the mocksaml ACS URL to:

```text
https://<chatwoot-host>/omniauth/saml/callback?account_id=<account-id>
```

- Set the mocksaml audience/SP entity ID to the value shown in Chatwoot
SAML settings, usually:

```text
https://<chatwoot-host>/saml/sp/<account-id>
```

- Use an email returned by mocksaml that exists in the SAML-enabled
account.
- Start login from Chatwoot's SSO login page.
- Confirm the callback redirects directly to the app login URL with an
SSO token.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-18 12:52:45 +05:30
Shivam MishraandGitHub ef27e571f7 feat: enable quoted reply for everyone (#14469)
Quoted email replies is now available to every account by default.
Previously this was gated behind the `quoted_email_reply` account-level
feature flag, so accounts needed it toggled on (via Super Admin) before
agents saw the toggle in the reply box.

## How to test

1. Open any conversation on an email inbox.
2. Confirm the **Quote previous email** toggle is visible in the reply
box (and is **not** visible on private notes or non-email channels).
3. Toggle it on, type a reply, and send — the outbound email should
include the quoted prior email below your message.
4. Toggle it off and send another reply — the quoted block should not
appear.
5. The toggle preference should persist per channel type (UI setting),
as before.
6. Verify the toggle works on a brand new account with no feature flags
flipped on (previously it would have been hidden).

## What changed

- Removed all `isFeatureEnabledonAccount(..., QUOTED_EMAIL_REPLY)` gates
from `ReplyBox.vue`, so the toggle and quoted-content behavior are
unconditional on email channels.
- Removed the `QUOTED_EMAIL_REPLY` constant from
`dashboard/featureFlags.js`.
- Marked the flag as `deprecated: true` in `config/features.yml` (kept
the entry in place to preserve FlagShihTzu bit positions on existing
accounts; `deprecated: true` hides it from the Super Admin UI).
- Dropped the now-unnecessary
`account.enable_features('quoted_email_reply')` setup from the message
builder spec.
2026-05-15 10:59:48 -07:00
1528fcde0c fix: missing widget es translations (#14375)
# Pull Request Template

## Description

There were English strings in the Spanish i18n file for the widget. This
PR translates them.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?


## 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
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-15 12:16:49 +05:30
5f6bd951b9 fix: portals#create returns 500 when custom_domain is omitted (#14400)
## Description

`POST /api/v1/accounts/:account_id/portals` returns a generic 500
(`{"status":500,"error":"Internal Server Error"}`) whenever the request
body omits `custom_domain`. Root cause: `parsed_custom_domain` calls
`URI.parse(@portal.custom_domain)` and `URI.parse(nil)` raises
`URI::InvalidURIError`. Existing callers either had to know to pass
`"custom_domain": ""` as a workaround or hit a 500 with no useful
diagnostic.

This PR guards `parsed_custom_domain` against blank values so the
existing fall-through (`else @portal.custom_domain`) applies —
equivalent to passing an empty string.

It also moves the `process_attached_logo` guard from the helper into the
`create` call site so `create` mirrors `update` (`process_attached_logo
if params[:blob_id].present?`) and avoids an unnecessary signed-blob
lookup on every create that doesn't include a logo.

Fixes #14397

## Type of change

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

## How Has This Been Tested?

Two new request specs in
`spec/controllers/api/v1/accounts/portals_controller_spec.rb` covering
the regression:

- `creates portal when custom_domain is omitted from request body` — the
previously-broken case, now returns 200.
- `creates portal when custom_domain is blank` — verifies the existing
workaround (`"custom_domain": ""`) still works after the change.

Manually verified against `chatwoot/chatwoot:latest` Docker image before
the fix (500) and against this branch (200) using the curl repro from
the issue.

```bash
curl -X POST "https://<host>/api/v1/accounts/<account_id>/portals" \
  -H "Content-Type: application/json" \
  -H "api_access_token: <token>" \
  -d '{"name":"Test Portal","slug":"test-portal","color":"#3b82f6"}'
```

Before: `{"status":500,"error":"Internal Server Error"}`
After: `200 OK` with the portal payload.

## 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 (no doc
change needed — controller behaviour, fully backward-compatible)
- [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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-15 11:11:18 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
cfc7699b7e chore(deps): bump net-imap from 0.4.20 to 0.4.24 (#14361)
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.20 to
0.4.24.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/net-imap/releases">net-imap's
releases</a>.</em></p>
<blockquote>
<h2>v0.4.24</h2>
<blockquote>
<p>[!IMPORTANT]
<em>The <code>0.4.x</code> release branch will only receive critical
security fixes, and will be unsupported when ruby 3.3 is EOL.
Please upgrade to a newer version.</em></p>
</blockquote>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release contains fixes for <strong>multiple
vulnerabilities</strong> concerning <em><strong><code>STARTTLS</code>
stripping</strong></em>, argument validation, and denial of service
attacks.</p>
<blockquote>
<p>[!WARNING]
<a
href="https://redirect.github.com/ruby/net-imap/pull/666">ruby/net-imap#666</a>
fixes a <code>STARTTLS</code> stripping vulnerability
(GHSA-vcgp-9326-pqcp).
Without this fix, a man-in-the-middle attacker can cause
<code>Net::IMAP#starttls</code> to return &quot;successfully&quot;,
<strong><em>without starting TLS</em></strong>.</p>
</blockquote>
<blockquote>
<p>[!IMPORTANT]
Argument validation is significantly improved. Several injection
vulnerabilities have been fixed:
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via Symbol arguments
(GHSA-75xq-5h9v-w6px).
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via the <code>attr</code> argument
to <code>#store</code>/<code>#uid_store</code> (GHSA-hm49-wcqc-g2xg)
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command/argument injection via the <code>storage_limit</code>
argument to <code>#setquota</code> (GHSA-hm49-wcqc-g2xg).
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>
fixes CRLF/command injection via <code>RawData</code>
(GHSA-hm49-wcqc-g2xg):</p>
<ul>
<li><code>#search</code> and <code>#uid_search</code> send
<code>criteria</code> as raw data, when it is a String</li>
<li><code>#fetch</code> and <code>#uid_fetch</code> send
<code>attr</code> as raw data, when it is a String.
When <code>attr</code> is an Array, its String members are sent as raw
data.</li>
</ul>
</blockquote>
<blockquote>
<p>[!CAUTION]
<code>RawData</code> does not defend against <em>other</em> forms of
argument injection! It is an intentionally low-level API.</p>
</blockquote>
<blockquote>
<p>[!NOTE]
Two denial of service vulnerabilities have been addressed.
These are generally only relevant when connecting to an <em>untrusted
hostile server</em> (or without TLS).</p>
<p><a
href="https://redirect.github.com/ruby/net-imap/pull/651">ruby/net-imap#651</a>
fixes quadratic time complexity when reading large responses containing
many string literals (GHSA-q2mw-fvj9-vvcw).
<a
href="https://redirect.github.com/ruby/net-imap/pull/655">ruby/net-imap#655</a>
adds a configurable <code>max_iterations</code> count for
<code>SCRAM-*</code> authentication (GHSA-87pf-fpwv-p7m7).</p>
<p>The default <code>ScramAuthenticator#max_iterations</code> is
<code>2**31 - 1</code> (max 32-bit signed int), which was already
OpenSSL's maximum value. <em>It provides no protection</em> against
hostile servers unless it is explicitly set to a lower value by the
user.</p>
</blockquote>
<h3>Added</h3>
<ul>
<li>🔒 Add <code>ScramAuthenticator#max_iterations</code> (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/654">#654</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/655">ruby/net-imap#655</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
</ul>
<h3>Fixed</h3>
<ul>
<li>🔒 Fix STARTTLS stripping vulnerability (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/666">ruby/net-imap#666</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
<li>🔒 Fix CRLF injection vulnerabilities (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/657">#657</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/658">#658</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/659">#659</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/660">#660</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/636">#636</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/661">#661</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a>,
reported by <a
href="https://github.com/manunio"><code>@​manunio</code></a></li>
<li>⚡ Much faster ResponseReader performance (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/642">#642</a>) in
<a
href="https://redirect.github.com/ruby/net-imap/pull/651">ruby/net-imap#651</a>,
reported by <a
href="https://github.com/Masamuneee"><code>@​Masamuneee</code></a></li>
<li>🐛 Wait to continue RawData literals (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/660">#660</a>) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/663">ruby/net-imap#663</a></li>
</ul>
<h3>Other Changes</h3>
<ul>
<li>♻️ Improve internal literal sending (partially backports <a
href="https://redirect.github.com/ruby/net-imap/issues/358">#358</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/616">#616</a>, <a
href="https://redirect.github.com/ruby/net-imap/issues/649">#649</a>) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/653">ruby/net-imap#653</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby/net-imap/compare/v0.4.23...v0.4.24">https://github.com/ruby/net-imap/compare/v0.4.23...v0.4.24</a></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/net-imap/commit/24a4e770b43230286a05aa2a9746cdbb3eb8485e"><code>24a4e77</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/666">#666</a>
from ruby/backport/v0.4/STARTTLS-stripping</li>
<li><a
href="https://github.com/ruby/net-imap/commit/63f53ffdefa3be6c779090bdbe6b7257b632b36c"><code>63f53ff</code></a>
🔖 Bump version to 0.4.24</li>
<li><a
href="https://github.com/ruby/net-imap/commit/038ae35d5ecbb2b85f77f4fa35e46604154dc8c4"><code>038ae35</code></a>
🍒 pick 24d5c773d: 🔒🥅 Handle tagged &quot;OK&quot; to incomplete command
[backport <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/705aa59faa28083f496dce50c0ee1eccca287506"><code>705aa59</code></a>
🍒 pick 62eea6ffe: 🔒🥅 Ensure STARTTLS tagged response was handled
[backport <a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/c9a6f28f8794cb991613d378b3920e5719062e29"><code>c9a6f28</code></a>
🍒 pick 46636cae8: ❌🔒 Add failing test for STARTTLS stripping [backport
<a
href="https://redirect.github.com/ruby/net-imap/issues/664">#664</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/aec06996eb87a7e1bbcef1f9f8926e8add2b8c71"><code>aec0699</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/663">#663</a>
from ruby/backport/v0.4/raw_data-warnings</li>
<li><a
href="https://github.com/ruby/net-imap/commit/fd245ddd1e220143c9b7c7861700590fbe9d35ef"><code>fd245dd</code></a>
🍒 pick be32e712e: 📚 Improve documentation of RawData arguments
[backports <a
href="https://redirect.github.com/ruby/net-imap/issues/661">#661</a>]</li>
<li><a
href="https://github.com/ruby/net-imap/commit/6dd110bfda6b47da9c294791ef16911bd1f49033"><code>6dd110b</code></a>
🍒 pick 47c72186d: 🐛 Validate RawData and wait to continue literals
[backports...</li>
<li><a
href="https://github.com/ruby/net-imap/commit/4e93149e65f6f1a6225ef770d0cd129acb5d63a9"><code>4e93149</code></a>
🔀 Merge branch 'backport/v0.4/QUOTA-argument-validation' into
backport/v0.4/s...</li>
<li><a
href="https://github.com/ruby/net-imap/commit/d2b23602e8617311900a9b3acf0c8d708b70bad4"><code>d2b2360</code></a>
🍒 pick 0ec4fd351: 🥅 Validate <code>#setquota</code> storage limit
argument [backports <a
href="https://redirect.github.com/ruby/net-imap/issues/659">#659</a>]</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/net-imap/compare/v0.4.20...v0.4.24">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.20&new-version=0.4.24)](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: Pranav <pranav@chatwoot.com>
2026-05-14 19:51:01 -07:00
dc332dd93e feat: add attachments endpoint for contact media view (#14391)
# Pull Request Template

## Description

This PR adds an endpoint to fetch all attachments shared with or by a
contact across all of their conversations.

Results are scoped based on the access:
* Admins can access all attachments
* Agents can access attachments only from inboxes they belong to
* Custom role agents are further filtered based on their conversation
permissions

Each attachment payload includes `conversation_id`, allowing the UI to
deep-link back to the source conversation.

Added `GET
/api/v1/accounts/:account_id/contacts/:contact_id/attachments` under the
existing contacts scope.

Fixes
https://linear.app/chatwoot/issue/CW-7021/add-media-view-to-the-contact-details-page

## Type of change

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


## Checklist:

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-14 21:34:39 +05:30
13f66e3a88 fix: incorrect scope across controllers (#14459)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-14 20:34:18 +05:30
fbcb89e955 fix(swagger): prevent path traversal in docs controller (#14458)
This hardens the development/test Swagger docs endpoint by ensuring
requested files are resolved only within the `swagger/` directory.

This did not affect production security because the Swagger controller
only renders files in development or test environments; production
already returns `404`. The change still closes the scanner finding and
prevents future automated reports from flagging the development-only
path.

## Closes

Addresses: GHSA-xhp7-ggjq-p2rg

## How to reproduce

1. Start Chatwoot locally in development.
2. Visit `/swagger/%2Fetc%2Fpasswd`.
3. Before this change, the endpoint could render files outside the
Swagger directory in development/test.

## What changed

- Resolve Swagger file requests relative to `Rails.root/swagger`.
- Return `404` when the resolved path is outside the Swagger directory
or does not point to a file.
- Strip leading slashes from derived request paths.
- Add a request spec for the encoded absolute-path case.

## How to test

1. Start the app locally.
2. Visit `/swagger` and confirm the ReDoc page loads.
3. Visit `/swagger/swagger.json` and confirm the Swagger JSON loads.
4. Visit `/swagger/%2Fetc%2Fpasswd` and confirm it returns `404` with no
file contents.

Note: `bundle exec rspec spec/controllers/swagger_controller_spec.rb`
was passing locally earlier during this fix. A final rerun before
opening the PR was blocked because local Postgres on `localhost:5432`
was not accepting connections.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-14 18:52:14 +05:30
Shivam MishraandGitHub 8712879681 test: Stabilize SafeFetch spec against constant-identity flake (#14454)
`spec/lib/safe_fetch_spec.rb` has been flaking intermittently under
full-suite runs with errors like:

```
expected SafeFetch::FileTooLargeError, got #<SafeFetch::FileTooLargeError: exceeded 1048576 bytes>
```

The class name on both sides is identical — yet RSpec reports a
mismatch. This PR replaces the constant-identity assertions in this spec
with a name-based matcher so the comparison stops depending on the live
Class object's identity. We have made a similar fix earlier, but that
wasn't addressing the core of the issue in #14139.


**How to reproduce:** The flake only surfaces under load. Running the
spec in isolation almost always passes, here's a [run failing in
CI](https://github.com/chatwoot/chatwoot/actions/runs/25852294516/job/75961520248?pr=14370)

## What's actually going on

Three facts combine:

1. **Test env reloads classes.** `config/environments/test.rb` sets
`cache_classes = false` so Zeitwerk reloads autoloadable code on demand.
2. **`lib/` is on the reloadable autoload tree.**
`config/application.rb` adds `lib` to `eager_load_paths`, which (with
`eager_load = false` in test) makes it lazily loaded by Zeitwerk's
*main* (reloading) autoloader. `lib/safe_fetch/` lives under that
umbrella.
3. **RSpec's `raise_error(Klass)` snapshots the Class object.**
`raise_error` matcher captures `Klass` (a specific `Class` instance)
when the matcher is built. At raise time it compares with `Module#===`,
which is identity-based.

When the executor between examples triggers
`Rails.application.reloader.reload!`, Zeitwerk does
`remove_const(:SafeFetch)` and re-installs an autoload trigger. The next
access produces a **fresh** `Class` object for
`SafeFetch::FileTooLargeError` — same name, different identity. The
matcher's snapshot now points at the dead Class, and the live raise
produces the new one. Identity fails, even though the error is
semantically correct.

This bites SafeFetch specifically because:

- SafeFetch is a *namespace* with 7 nested error classes — one reload
invalidates all of them.
- The spec contains 14+ `raise_error(SafeFetch::Foo)` assertions — many
chances to land in a reload window.
- SafeFetch is exercised by request-driven code (webhook delivery,
avatar fetch). Earlier specs in the suite warm up the reloader
machinery, which then fires during this spec.

Other custom-error specs don't visibly flake because their consumers
`rescue ConstName => e` (dynamic class lookup at raise time, walks the
ancestor chain via `Module#===` *at the moment of raise*, with no
captured snapshot), rather than RSpec's snapshot-then-compare pattern.

## What this PR does

Adds a small local helper to the spec that matches by class name string,
not by Class identity:

```ruby
def safe_fetch_error(name, message_pattern = nil)
  satisfy("raise SafeFetch::#{name}#{" matching #{message_pattern.inspect}" if message_pattern}") do |error|
    error.class.name == "SafeFetch::#{name}" &&
      (message_pattern.nil? || message_pattern.match?(error.message))
  end
end
```

Every `raise_error(described_class::FooError)` becomes
`raise_error(safe_fetch_error('FooError'))`. Class names are strings;
string equality survives any number of reloads. The semantic assertion
("this raised the right kind of error") is preserved.

This is the pattern CLAUDE.md already endorses for this codebase:

> Specs in parallel/reloading environments: prefer comparing
`error.class.name` over constant class equality when asserting raised
errors.

## Why this approach (even though it's suboptimal)

To be honest with reviewers: **this is a workaround, not a root-cause
fix.** Future spec authors who use `raise_error(SafeFetch::Foo)` in
other files will hit the same flake. The "real" fix removes the failure
mode at the source rather than dodging it per-spec.

Here's the menu of options we considered and the tradeoffs:

### Option A — Spec-side name matcher (this PR)

- **What:** the helper above.
- **Pro:** one-file change, zero blast radius beyond the spec.
- **Pro:** matches CLAUDE.md's documented stance.
- **Con:** every new spec touching reloadable error classes needs to
remember this pattern. It's a discipline tax, not a structural fix.
- **Verdict:** chosen for this PR.

### Option B — Pin SafeFetch outside Zeitwerk

```ruby
# config/application.rb
Rails.autoloaders.main.ignore(
  Rails.root.join('lib/safe_fetch.rb'),
  Rails.root.join('lib/safe_fetch'),
)
require Rails.root.join('lib/safe_fetch')
```

- **Pro:** real root-cause fix. `SafeFetch::*` constants become
process-lifetime stable. The spec helper becomes unnecessary, every
assertion in any spec works correctly.
- **Pro:** preserves the public API exactly.
- **Con:** loses hot-reload for SafeFetch in dev (need server restart to
see edits).
- **Con:** modifies `application.rb`, which has cross-team review
weight.
- **Verdict:** rejected for scope reasons in this PR; a sensible
follow-up.

### Option C — Move error classes to a non-reloadable location

Define top-level error classes in
`config/initializers/safe_fetch_errors.rb`. Initializers run once at
boot, constants are never reloaded.

- **Pro:** identity-stable errors, no spec helper needed.
- **Con:** API change — `SafeFetch::FileTooLargeError` becomes
`SafeFetchFileTooLargeError` (or similar). Every consumer's `rescue`
clause has to update.
- **Con:** namespace pollution at top-level.
- **Verdict:** rejected. The constraint of touching initializers is what
motivated the simpler PR.

### Option D — Vendor SafeFetch as a path gem

Move `lib/safe_fetch/` → `vendor/gems/safe_fetch/` with a gemspec.
Bundler `require`s gems once; Zeitwerk has zero involvement.

- **Pro:** structurally the most correct fix. Same identity stability as
Option B, no Zeitwerk plumbing required.
- **Pro:** zero API change for consumers.
- **Con:** larger refactor (6+ files moved, gemspec authored,
Gemfile/Gemfile.lock updated).
- **Con:** SafeFetch becomes harder to iterate on in dev (gem-style
edit-then-restart loop).
- **Verdict:** rejected for scope in this PR; the cleanest long-term
home for a security primitive.

### Option E — Drop custom errors, return a `Result`

Refactor SafeFetch to yield a result hash (`{ ok: true, ... }` / `{ ok:
false, kind: :unsafe_url, ... }`) instead of raising for anticipated
outcomes. Failure kinds become symbols, which are interned for the
process lifetime.

- **Pro:** eliminates the failure mode at its true root — there are no
custom exception classes to reload, anywhere.
- **Pro:** forces explicit, exhaustive handling at every call site — a
feature for a security primitive.
- **Pro:** spec assertions become data assertions (`expect(result).to
match(ok: false, kind: :too_large)`), which are robust against any
reload, any reordering.
- **Con:** paradigm shift away from the exception-driven style used
everywhere else in the codebase.
- **Con:** every consumer rewrites their `rescue` block into
pattern-matched handling.
- **Verdict:** rejected for scope in this PR; the architecturally
cleanest answer if we ever revisit SafeFetch's API.

## Why we're shipping A despite knowing B–E are better

This flake has been chewing CI time intermittently and previously took a
partial fix (#14139). We need it stable *now*. Options B–E are real
refactors with broader review surface (`application.rb`, consumer code,
or the lib's public contract). Option A:

- Costs nothing — one helper, mechanical replacements.
- Doesn't preclude any of B/C/D/E later. The helper goes away cleanly
once a root-cause fix lands.
- Aligns with the project's documented guidance for this scenario.

When SafeFetch next gets a substantive change, that's the right moment
to fold in B or D. Until then, the spec is stable and CI gets its time
back.

## What changed

- `spec/lib/safe_fetch_spec.rb`: added `safe_fetch_error(name,
message_pattern = nil)` helper; converted all 14
`raise_error(described_class::FooError[, /regex/])` assertions to
`raise_error(safe_fetch_error('FooError'[, /regex/]))`.
2026-05-14 16:48:14 +05:30
ffbf40c720 fix: harden Active Storage direct uploads and proxy streaming (#14440)
Hardens Active Storage handling on Rails 7.1 by filtering internal
direct-upload metadata keys and limiting proxy range requests, while
keeping audio playback on redirect URLs so large recordings are not
routed through the proxy limiter.

Closes
- CVE-2026-33173
- CVE-2026-33174
- CVE-2026-33658

Why
Rails 7.1 does not currently have patched releases for these Active
Storage advisories, and Chatwoot exposes Active Storage direct-upload
endpoints and media URLs. This keeps the Rails dependency unchanged
while adding small local mitigations until Rails can be upgraded to
7.2.3.1+.

What changed
- Filters `identified`, `analyzed`, and `composed` from direct-upload
blob metadata.
- Limits Active Storage proxy range requests to one range under 100 MB.
- Uses redirect URLs for inline audio attachments so normal playback of
large recordings avoids the proxy streaming path.
- Adds scoped bundle-audit ignores for the locally mitigated Active
Storage advisories and the remaining Rails advisories that are not
reachable through current Chatwoot usage.

How to test
- Upload an attachment from the dashboard reply composer and confirm it
sends successfully.
- Upload an attachment from the website widget and confirm it appears in
the conversation.
- POST a direct-upload request with `blob.metadata.identified`,
`blob.metadata.analyzed`, and `blob.metadata.composed`; confirm those
keys are not persisted while custom metadata remains.
- Play an audio/call-recording attachment and confirm the audio URL
loads through Active Storage redirect rather than proxy.
- Run `bundle exec bundle audit check -v`.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-14 14:50:29 +05:30
dd7f5c27e5 perf: eliminate N+1 queries on inboxes#index (#14451)
The `inboxes#index` API endpoint was firing 3 queries per inbox while
rendering the response — one each for the polymorphic channel, avatar
attachment, and working hours. For accounts with many inboxes this
turned a routine list call into a multi-second request — measured at 35
seconds on an account with 5,000 inboxes.

Two root causes:
1. `policy_scope` was silently discarding the controller's
`.includes(...)` chain because `InboxPolicy::Scope#resolve` returns a
fresh `user.assigned_inboxes` relation, ignoring whatever scope is
passed in. So the eager loading never actually applied.
2. `Inbox#weekly_schedule` called `working_hours.order(...).select(...)`
which fires a new query per inbox even when the association is
preloaded.

The fix chains the eager loads and ordering after `policy_scope` so they
apply to the relation the policy actually returns, adds `:portal` and
`:working_hours` to the preload list, and refactors `weekly_schedule` to
sort the preloaded collection in Ruby and build the hash directly.

Result: queries drop from O(n) to a constant ~15 regardless of inbox
count, and total request time drops 5–6× across every account size
tested. Response payload is byte-identical.

## How to test

1. Open the agent dashboard for an account with a large number of
inboxes (1000+). On a stock dev DB you can seed quickly with
`Seeders::AccountSeeder` or by creating a few hundred `Channel::Api`
inboxes via the rails console.
2. Hit any UI surface that lists inboxes (settings → inboxes, sidebar
inbox list, agent assignment dropdowns). Should feel near-instant where
it previously hung.
3. Confirm every inbox shows the same data as before — channel type,
working hours, avatar, portal/help-center link. No fields should be
missing or different.

## Benchmarks

Real HTTP requests via curl against a dev Rails server (median of 5
timed trials after warmup; "Server total" is the time reported in Rails'
`Completed 200 OK in <X>ms` log line).

| Account | Channel mix | OLD | NEW | Speedup |
|---|---|---|---|---|
| 1000 inboxes | 3 types, no portals/avatars | 5.87 s | 0.96 s |
**6.1×** |
| 5000 inboxes | 3 types, no portals/avatars | 33.07 s | 6.93 s |
**4.8×** |
| 5000 inboxes | 10 types, 25% portals, 10% avatars | 35.04 s | 6.55 s |
**5.4×** |

Detailed breakdown for the realistic 5000-inbox case:

|                 | OLD       | NEW      | Δ        |
|-----------------|-----------|----------|----------|
| Server total    | 35,042 ms | 6,550 ms | −81%     |
| Views           | 32,034 ms | 6,452 ms | −80%     |
| ActiveRecord    | 2,948 ms  | 92 ms    | −97%     |
| Allocations     | 42.3 M    | 11.3 M   | −73%     |
| Queries         | ~15,000   | 15       | −99.9%   |

## What changed

- `app/controllers/api/v1/accounts/inboxes_controller.rb` — chain
`.includes(:channel, :portal, :working_hours, avatar_attachment:
:blob).order_by_name` *after* `policy_scope(...)`. The previous code
passed them into `policy_scope` but the policy resolver dropped the
chain.
- `app/models/concerns/out_of_offisable.rb` — `weekly_schedule` now
sorts the preloaded `working_hours` collection in Ruby and constructs
the result hashes inline, avoiding both the per-inbox query from
`.order(...).select(...)` and the `as_json` reflection overhead.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-05-14 14:20:52 +05:30
c2523c5d8b fix(editor): Refresh reply editor when reply window reopens in real-time (#14446)
On WhatsApp and any channel that disables the reply editor outside its
messaging window (WhatsApp Cloud, Twilio WhatsApp, API channels with
`agent_reply_time_window` set), when the window was already expired and
a new inbound message arrived in real-time, the "messaging restricted"
banner correctly hid but the editor itself stayed un-typeable until the
agent refreshed the page. This made the dashboard look like it accepted
replies even though typing did nothing.

Fixes
[CW-7087](https://linear.app/chatwoot/issue/CW-7087/reply-editor-stays-disabled-after-real-time-incoming-message-reopens)

#### How to reproduce

1. Open a WhatsApp conversation whose last incoming message is older
than 24h, so `can_reply` is `false` (banner shown, editor greyed out).
2. With the dashboard open on that conversation, have the customer send
a fresh inbound message (or simulate one via the channel's webhook).
3. Before the fix: banner disappears, editor wrapper loses its disabled
styling, but clicking into the editor and typing does nothing — refresh
required.
4. After the fix: banner disappears and the editor accepts input
immediately.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-14 12:16:58 +04:00
Shivam MishraandGitHub 05bda5f742 feat: don't let onboarding write domain (#14442)
Stop the onboarding flow from writing the user's company website into
`accounts.domain`. That column is reserved for the inbound email domain
used to construct reply-to addresses (`reply+<uuid>@<domain>`), and
silently overloading it from onboarding was breaking email continuity
for accounts whose domain MX didn't point at Chatwoot's inbound —
customer replies were going to an unreachable address.

The website value now lives in `custom_attributes.website`, which is
what the rest of the app already treats as the "company website" field.
2026-05-13 20:09:48 +05:30
379e28df1f fix: prevent bot metrics double-counting when handoff and resolution coexist [CW-6210] (#14032)
The bot metrics dashboard can show `handoff_rate + resolution_rate >
100%`. A single conversation can accumulate both
`conversation_bot_handoff` and `conversation_bot_resolved` events, and
the rate queries count them independently against a shared denominator.

## How it happens

```
Customer messages bot inbox
        │
        ▼
   ┌──────────┐
   │ pending  │ (bot handling)
   └────┬─────┘
        │ bot can't help
        ▼
   ┌──────────┐
   │   open   │ (handed off → conversation_bot_handoff event created)
   └────┬─────┘
        │ agent clicks "Resolve" WITHOUT sending a message
        ▼
   ┌──────────┐
   │ resolved │ conversation_resolved fires
   └──────────┘
        │
        ▼
   create_bot_resolved_event guard checks:
     ✅ inbox.active_bot?
     ✅ no outgoing messages with sender_type: 'User'  ← agent never messaged!
        │
        ▼
   conversation_bot_resolved event ALSO created ← BUG
        │
        ▼
   Same conversation counted in BOTH rates → sum exceeds 100%
```

## Why fix at the read path, not the write path

An earlier attempt added guards in the listener to make the two events
mutually exclusive per conversation — deleting `bot_resolved` when a
handoff fires, suppressing resolutions when a handoff exists. This was
rejected because conversations can be reopened across multiple cycles
(bot resolves on day 1, customer returns on day 5, bot hands off).
Deleting the day-1 resolution corrupts historical reports, and the async
event dispatcher makes listener-level guards vulnerable to race
conditions.

## What this PR does

Within a reporting window, if a conversation has both events, **handoff
wins** — the conversation is excluded from the resolution count. This is
applied via SQL subquery across all three read paths:

```
                    ┌─────────────────────────┐
                    │   Reporting Events DB    │
                    │                          │
                    │  conv_bot_handoff: [A,B] │
                    │  conv_bot_resolved: [A,C]│
                    └────────┬────────────────┘
                             │
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
       BotMetricsBuilder  ReportHelper  CountReportBuilder
       (rate cards)       (bot_summary)  (timeseries charts)
              │              │              │
              ▼              ▼              ▼
       resolutions:        resolutions:   resolutions:
       [A,C] minus [A,B]  same logic     same logic
       = [C] only          = [C] only     = [C] only

       Result: Conversation A → handoff only
               Conversation B → handoff only
               Conversation C → resolution only
```

For wide date ranges spanning multiple lifecycles, a conversation
bot-resolved in one cycle and handed off in a later cycle will only show
as a handoff. This is an acceptable tradeoff — the alternative (>100%
rates) is clearly worse, and narrow ranges handle this correctly since
the events fall into different windows. No reporting events are
modified, so historical data stays intact.

## Diagnostic tool

`rake bot_metrics:diagnose` — read-only task that prompts for account ID
and date range, shows a before/after rate comparison without modifying
data.

---------

Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-05-13 18:43:23 +05:30
Aarav UniyalandGitHub c6dceb0e07 fix: contacts dropdown overlap (#14305) 2026-05-13 15:28:58 +05:30
Sivin VargheseandGitHub cd33cea69f chore: Update nl translation in widget (#14441) 2026-05-13 14:18:25 +05:30
102 changed files with 1191 additions and 243 deletions
+15 -4
View File
@@ -2,8 +2,19 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
+1 -1
View File
@@ -8,7 +8,7 @@ ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.33.4"
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
+3 -3
View File
@@ -212,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -574,7 +574,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
net-imap (0.4.24)
date
net-protocol
net-pop (0.1.2)
@@ -961,7 +961,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.3)
timeout (0.6.1)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
+15 -4
View File
@@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
# Exclude conversations that also had a handoff in the same range — handoff wins
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def bot_resolution_rate
@@ -0,0 +1,18 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes(:message)
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
end
def show; end
@@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def fetch_agent_bot
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
end
def create_channel
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
prompt: 'consent'
state: state
}
)
if redirect_url
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.find(params[:id])
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
end
def set_current_page
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
process_attached_logo if params[:blob_id].present?
end
def update
@@ -87,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -98,6 +98,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def settings_params
@@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
@message = @web_widget.inbox.messages.find(permitted_params[:id])
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
end
end
@@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end
+8 -1
View File
@@ -44,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
imap_login: imap_login_identity, imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -55,6 +55,13 @@ class OauthCallbackController < ApplicationController
})
end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name
raise NotImplementedError
end
@@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
redirect_to super_admin_settings_path, flash: success_flash
end
end
@@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
+8 -3
View File
@@ -1,7 +1,12 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
render inline: Rails.root.join('swagger', derived_path).read
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
else
head :not_found
end
@@ -11,8 +16,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path
end
end
+7 -4
View File
@@ -53,13 +53,12 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
end
def bot_handoffs
@@ -67,6 +66,10 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
@@ -32,7 +32,7 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-20 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -803,6 +803,11 @@ watch(
}
);
watch(
computed(() => props.disabled),
() => editorView?.setProps({})
);
watch(
computed(() => props.updateSelectionWith),
(newValue, oldValue) => {
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReplyToMessage from './ReplyToMessage.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
@@ -144,8 +143,6 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -384,14 +381,8 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
if (!this.isAnEmailChannel) {
return false;
}
@@ -416,11 +407,7 @@ export default {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
return this.isAnEmailChannel && !this.isOnPrivateNote;
},
shouldShowQuotedPreview() {
return (
@@ -577,7 +564,6 @@ export default {
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
-1
View File
@@ -43,7 +43,6 @@ export const FEATURE_FLAGS = {
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
@@ -46,6 +46,7 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Cancel"
},
"API": {
@@ -134,7 +134,7 @@ const populateFormFields = () => {
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = account?.domain || brandInfo?.domain || '';
website.value = attrs.website || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
@@ -216,7 +216,7 @@ const handleSubmit = async () => {
await updateAccount({
name: accountName.value,
locale: locale.value,
domain: website.value,
website: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
@@ -63,6 +63,13 @@ export default {
isIntegrationDialogflow() {
return this.integration.id === 'dialogflow';
},
submitButtonLabel() {
if (this.integration.id === 'openai' && this.uiFlags.isCreatingHook) {
return this.$t('INTEGRATION_APPS.ADD.FORM.VALIDATING_OPENAI');
}
return this.$t('INTEGRATION_APPS.ADD.FORM.SUBMIT');
},
},
methods: {
onClose() {
@@ -154,7 +161,7 @@ export default {
/>
<NextButton
type="submit"
:label="$t('INTEGRATION_APPS.ADD.FORM.SUBMIT')"
:label="submitButtonLabel"
:is-loading="uiFlags.isCreatingHook"
/>
</div>
@@ -121,7 +121,7 @@ export const actions = {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
throw new Error(error);
throw error;
}
},
deleteHook: async ({ commit }, { appId, hookId }) => {
@@ -105,7 +105,9 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue(errorMessage);
await expect(actions.createHook({ commit })).rejects.toThrow(Error);
await expect(actions.createHook({ commit })).rejects.toEqual(
errorMessage
);
expect(commit.mock.calls).toEqual([
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }],
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }],
+8 -8
View File
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "No disponible"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "Estamos en línea",
"OFFLINE": "Estamos ausentes en este momento",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"BACK_AS_SOON_AS_POSSIBLE": "Volveremos tan pronto como sea posible"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Normalmente responde en unos minutos",
"IN_A_FEW_HOURS": "Normalmente responde en unas pocas horas",
"IN_A_DAY": "Normalmente responde en un día",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"BACK_IN_HOURS": "Volveremos a estar en línea en {n} hora | Volveremos a estar en línea en {n} horas",
"BACK_IN_MINUTES": "Volveremos a estar en línea en {time} minutos",
"BACK_AT_TIME": "Volveremos a estar en línea a las {time}",
"BACK_ON_DAY": "Volveremos a estar en línea el {day}",
"BACK_TOMORROW": "Volveremos a estar en línea mañana",
"BACK_IN_SOME_TIME": "Volveremos a estar en línea dentro de un rato"
},
"DAY_NAMES": {
"SUNDAY": "Domingo",
+8 -8
View File
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Niet beschikbaar"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We zijn online",
"OFFLINE": "We zijn momenteel afwezig",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"BACK_AS_SOON_AS_POSSIBLE": "We zijn zo snel mogelijk weer terug"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Reageert meestal binnen een paar minuten",
"IN_A_FEW_HOURS": "Reageert meestal binnen een paar uur",
"IN_A_DAY": "Reageert meestal binnen een dag",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"BACK_IN_HOURS": "We zullen weer online zijn in {n} uur | We zijn weer online in {n} uur",
"BACK_IN_MINUTES": "We zijn weer online in {time} minuten",
"BACK_AT_TIME": "We zijn weer online op {time}",
"BACK_ON_DAY": "We zijn weer online op {day}",
"BACK_TOMORROW": "We zullen morgen weer online zijn",
"BACK_IN_SOME_TIME": "We zijn weer online binnen een bepaalde tijd"
},
"DAY_NAMES": {
"SUNDAY": "zondag",
+3 -1
View File
@@ -8,6 +8,7 @@ class BulkActionsJob < ApplicationJob
def perform(account:, params:, user:)
@account = account
@user = user
Current.user = user
@params = params
@records = records_to_updated(params[:ids])
@@ -61,6 +62,7 @@ class BulkActionsJob < ApplicationJob
current_model = @params[:type].camelcase
return unless MODEL_TYPE.include?(current_model)
current_model.constantize&.where(account_id: @account.id, display_id: ids)
scope = current_model.constantize.where(account_id: @account.id, display_id: ids)
Conversations::PermissionFilterService.new(scope, @user, @account).perform
end
end
@@ -0,0 +1,23 @@
class Migration::ValidateOpenaiHooksJob < ApplicationJob
queue_as :async_database_migration
def perform(account: nil)
scope = Integrations::Hook.where(app_id: 'openai', status: 'enabled')
scope = scope.where(account_id: account.id) if account
stats = { checked: 0, destroyed: 0 }
scope.find_each do |hook|
stats[:checked] += 1
next if Integrations::Openai::KeyValidator.valid?(hook.settings&.dig('api_key'))
AdministratorNotifications::IntegrationsNotificationMailer.with(account: hook.account).openai_disconnect.deliver_later
hook.destroy!
stats[:destroyed] += 1
Rails.logger.warn("[openai-backfill] destroyed hook_id=#{hook.id} account_id=#{hook.account_id}")
end
Rails.logger.info("[openai-backfill] completed #{stats.inspect}")
stats
end
end
@@ -1,14 +1,14 @@
class Notification::DeleteNotificationJob < ApplicationJob
queue_as :low
def perform(user, type: :all)
def perform(user, account, type: :all)
notifications = user.notifications.where(account_id: account.id)
ActiveRecord::Base.transaction do
if type == :all
# Delete all notifications
user.notifications.destroy_all
notifications.destroy_all
elsif type == :read
# Delete only read notifications
user.notifications.where.not(read_at: nil).destroy_all
notifications.where.not(read_at: nil).destroy_all
end
end
end
@@ -9,4 +9,10 @@ class AdministratorNotifications::IntegrationsNotificationMailer < Administrator
subject = 'Your Dialogflow integration was disconnected'
send_notification(subject)
end
def openai_disconnect
subject = 'Your OpenAI integration was disconnected'
action_url = settings_url('integrations/openai')
send_notification(subject, action_url: action_url)
end
end
+3
View File
@@ -38,6 +38,9 @@ class Account < ApplicationRecord
}.freeze
validates :name, presence: true
# `domain` is the inbound email domain used to construct reply addresses
# (see `inbound_email_domain`). Do not repurpose it for a website or any
# non-mail-related domain.
validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
+2 -6
View File
@@ -104,9 +104,7 @@ class Attachment < ApplicationRecord
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
# which makes <audio> elements download instead of play. Force inline so the
# call-recording chip (and any other audio bubble) can stream directly.
# Keep audio playback inline while avoiding the ActiveStorage proxy path.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
@@ -116,9 +114,7 @@ class Attachment < ApplicationRecord
def inline_audio_url
return '' unless file.attached?
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
Rails.application.routes.url_helpers.rails_storage_redirect_url(file, disposition: 'inline')
end
def file_metadata
+11 -1
View File
@@ -19,7 +19,17 @@ module OutOfOffisable
end
def weekly_schedule
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
working_hours.sort_by(&:day_of_week).map do |wh|
{
'day_of_week' => wh.day_of_week,
'closed_all_day' => wh.closed_all_day,
'open_hour' => wh.open_hour,
'open_minutes' => wh.open_minutes,
'close_hour' => wh.close_hour,
'close_minutes' => wh.close_minutes,
'open_all_day' => wh.open_all_day
}
end
end
# accepts an array of hashes similiar to the format of weekly_schedule
+13
View File
@@ -15,6 +15,19 @@
# index_installation_configs_on_name_and_created_at (name,created_at) UNIQUE
#
class InstallationConfig < ApplicationRecord
CAPTAIN_LLM_CONFIG_KEYS = %w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_OPEN_AI_MODEL
].freeze
RESTART_REQUIRED_CONFIG_KEYS = (CAPTAIN_LLM_CONFIG_KEYS + %w[
LANGFUSE_BASE_URL
LANGFUSE_PUBLIC_KEY
LANGFUSE_SECRET_KEY
OTEL_PROVIDER
]).freeze
# https://stackoverflow.com/questions/72970170/upgrading-to-rails-6-1-6-1-causes-psychdisallowedclass-tried-to-load-unspecif
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
+25
View File
@@ -29,6 +29,7 @@ class Integrations::Hook < ApplicationRecord
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validate :validate_openai_api_key, if: :validate_openai_api_key?
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
@@ -56,6 +57,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
def openai?
app_id == 'openai'
end
def notion?
app_id == 'notion'
end
@@ -95,6 +100,26 @@ class Integrations::Hook < ApplicationRecord
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
# TODO: When adding credential validation for other integrations (dialogflow, dyte, etc.),
# extract this into an app-level config flag in apps.yml instead of hardcoding app_id checks.
def validate_openai_api_key?
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
end
def openai_api_key_changed?
settings_api_key(settings) != settings_api_key(settings_in_database)
end
def validate_openai_api_key
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
end
def settings_api_key(value)
value&.dig('api_key') || value&.dig(:api_key)
end
def trigger_setup_if_crm
# we need setup services to create data prerequisite to functioning of the integration
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
@@ -55,7 +55,7 @@ class Onboarding::WebWidgetCreationService
end
def website_url
@account.domain.presence || brand_info[:domain].presence
@account.custom_attributes['website'].presence || brand_info[:domain].presence
end
def widget_color
+9
View File
@@ -83,11 +83,20 @@ class Reports::RawDataSource < Reports::DataSource
created_at: range
)
return events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) if raw_count_strategy == :exclude_bot_handoffs
return events unless raw_count_strategy == :distinct_conversation
events.joins(:conversation).select(:conversation_id).distinct
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events.where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).where.not(conversation_id: nil).select(:conversation_id)
end
def summary_scope
scope = account.reporting_events.where(created_at: range)
return scope.joins(:conversation) if dimension_type == 'team'
@@ -79,8 +79,8 @@ module Reports::ReportMetricRegistry
bot_resolutions_count: Metric.new(
name: :bot_resolutions_count,
aggregate: :count,
raw_event_name: :conversation_bot_resolved,
rollup_metric: :bot_resolutions_count
raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count,
raw_count_strategy: :exclude_bot_handoffs
),
bot_handoffs_count: Metric.new(
name: :bot_handoffs_count,
@@ -0,0 +1,8 @@
json.meta do
json.total_count @attachments_count
end
json.payload @attachments do |attachment|
json.partial! 'api/v1/models/attachment', formats: [:json], attachment: attachment
json.conversation_id attachment.message.conversation.display_id
end
@@ -3,15 +3,5 @@ json.meta do
end
json.payload @attachments do |attachment|
json.id attachment.push_event_data[:id]
json.message_id attachment.push_event_data[:message_id]
json.thumb_url attachment.push_event_data[:thumb_url]
json.data_url attachment.push_event_data[:data_url]
json.file_size attachment.push_event_data[:file_size]
json.file_type attachment.push_event_data[:file_type]
json.extension attachment.push_event_data[:extension]
json.width attachment.push_event_data[:width]
json.height attachment.push_event_data[:height]
json.created_at attachment.message.created_at.to_i
json.sender attachment.message.sender.push_event_data if attachment.message.sender
json.partial! 'api/v1/models/attachment', formats: [:json], attachment: attachment
end
@@ -6,6 +6,7 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
@@ -0,0 +1,13 @@
attachment_data = attachment.push_event_data
json.id attachment_data[:id]
json.message_id attachment_data[:message_id]
json.thumb_url attachment_data[:thumb_url]
json.data_url attachment_data[:data_url]
json.file_size attachment_data[:file_size]
json.file_type attachment_data[:file_type]
json.extension attachment_data[:extension]
json.width attachment_data[:width]
json.height attachment_data[:height]
json.created_at attachment.message.created_at.to_i
json.sender attachment.message.sender.push_event_data if attachment.message.sender
@@ -0,0 +1,7 @@
<p>Hello,</p>
<p>Your OpenAI integration was disconnected because the configured API key is invalid or revoked. To continue using OpenAI-powered features, reconnect the integration with a valid API key.</p>
<p>
Click <a href="{{action_url}}">here</a> to reconnect.
</p>
+1 -1
View File
@@ -219,11 +219,11 @@
- name: quoted_email_reply
display_name: Quoted Email Reply
enabled: false
deprecated: true
- name: companies
display_name: Companies
enabled: false
premium: true
chatwoot_internal: true
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
+41
View File
@@ -11,3 +11,44 @@ Rails.application.config.active_storage.content_types_allowed_inline += %w[
audio/wav
audio/x-wav
]
module ActiveStorageDirectUploadMetadataFilter
INTERNAL_METADATA_KEYS = %w[identified analyzed composed].freeze
private
def blob_args
super.tap do |args|
args[:metadata]&.except!(*INTERNAL_METADATA_KEYS, *INTERNAL_METADATA_KEYS.map(&:to_sym))
end
end
end
module ActiveStorageProxyRangeLimit
STREAMING_MAX_RANGES = 1
STREAMING_CHUNK_MAX_SIZE = 100.megabytes
private
def send_blob_byte_range_data(blob, range_header, disposition: nil)
ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
return head(:range_not_satisfiable) unless valid_ranges?(ranges)
super
end
def valid_ranges?(ranges)
ranges.present? &&
ranges.any?(&:present?) &&
ranges.length <= STREAMING_MAX_RANGES &&
ranges.sum { |range| range.end - range.begin } < STREAMING_CHUNK_MAX_SIZE
end
end
Rails.application.config.to_prepare do
unless ActiveStorage::DirectUploadsController < ActiveStorageDirectUploadMetadataFilter
ActiveStorage::DirectUploadsController.prepend(ActiveStorageDirectUploadMetadataFilter)
end
ActiveStorage::Streaming.prepend(ActiveStorageProxyRangeLimit) unless ActiveStorage::Streaming < ActiveStorageProxyRangeLimit
end
+2
View File
@@ -123,6 +123,8 @@ en:
sdp_offer_required: 'sdp_offer is required'
contact_phone_required: 'Contact phone number is required'
permission_request_failed: 'Failed to send call permission request'
openai:
invalid_api_key: 'OpenAI API key is invalid or revoked. Please check your key in your OpenAI dashboard.'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
+1
View File
@@ -209,6 +209,7 @@ Rails.application.routes.draw do
resources :contact_inboxes, only: [:create]
resources :labels, only: [:create, :index]
resources :notes
get :attachments, to: 'attachments#index'
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
end
end
@@ -0,0 +1,7 @@
class EnqueueValidateOpenaiHooksJob < ActiveRecord::Migration[7.1]
def up
Migration::ValidateOpenaiHooksJob.perform_later
end
def down; end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
+2 -2
View File
@@ -229,7 +229,7 @@ function install_dependencies() {
nodejs patch ruby-dev zlib1g-dev liblzma-dev \
libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev sudo \
libvips python3-pip
npm install -g pnpm@10.33.4
npm install -g pnpm
}
##############################################################################
@@ -940,7 +940,7 @@ function get_pnpm() {
return
fi
echo "pnpm is not installed. Installing pnpm..."
npm install -g pnpm@10.33.4
npm install -g pnpm
echo "Cleaning up existing node_modules directory..."
sudo -i -u chatwoot << "EOF"
cd chatwoot
+2 -2
View File
@@ -3,7 +3,7 @@ FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.33.4"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -99,7 +99,7 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.33.4"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -1,26 +1,8 @@
module Enterprise::DeviseOverrides::OmniauthCallbacksController
def saml
# Call parent's omniauth_success which handles the auth
omniauth_success
end
def redirect_callbacks
# derive target redirect route from 'resource_class' param, which was set
# before authentication.
devise_mapping = get_devise_mapping
redirect_route = get_redirect_route(devise_mapping)
return omniauth_success if params[:provider] == 'saml'
# preserve omniauth info for success route. ignore 'extra' in twitter
# auth response to avoid CookieOverflow.
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
session['dta.omniauth.params'] = request.env['omniauth.params']
# For SAML, use 303 See Other to convert POST to GET and preserve session
if params[:provider] == 'saml'
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
else
super
end
super
end
def omniauth_success
@@ -33,7 +15,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
end
def omniauth_failure
return super unless params[:provider] == 'saml'
return super unless params[:strategy] == 'saml'
relay_state = saml_relay_state
error = params[:message] || 'authentication-failed'
@@ -63,11 +45,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
end
def extract_saml_account_id
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
params[:account_id] || request.env['omniauth.params']&.dig('account_id')
end
def saml_relay_state
session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
params[:RelayState] || request.env['omniauth.params']&.dig('RelayState')
end
def auth_hash
request.env['omniauth.auth'] || super
end
def for_mobile?(relay_state)
+8
View File
@@ -23,6 +23,8 @@
class Company < ApplicationRecord
include Avatarable
ACTIVITY_ROLLUP_INTERVAL = 5.minutes
validates :account_id, presence: true
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
validates :domain, allow_blank: true, format: {
@@ -58,6 +60,12 @@ class Company < ApplicationRecord
)
}
def record_activity_at!(activity_at)
return if last_activity_at.present? && last_activity_at > activity_at - ACTIVITY_ROLLUP_INTERVAL
update!(last_activity_at: activity_at)
end
private
def prepare_jsonb_attributes
@@ -6,6 +6,7 @@ module Enterprise::Concerns::Contact
after_commit :associate_company_from_email,
on: [:create, :update],
if: :should_associate_company?
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
end
private
@@ -28,4 +29,8 @@ module Enterprise::Concerns::Contact
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
# Don't fail the contact save if the company association fails
end
def record_company_activity
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
end
end
@@ -7,6 +7,7 @@ class Companies::ContactMembershipService
def assign(contact:)
contact.update!(company: company)
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
end
def remove(contact:)
@@ -9,6 +9,7 @@ class Contacts::CompanyAssociationService
contact.update_column(:company_id, company.id)
Company.increment_counter(:contacts_count, company.id)
# rubocop:enable Rails/SkipsModelValidations
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
end
company
end
@@ -7,14 +7,12 @@ SAML_SETUP_PROC = proc do |env|
# Extract account_id from various sources
account_id = request.params['account_id'] ||
request.session[:saml_account_id] ||
env['omniauth.params']&.dig('account_id')
relay_state = request.params['RelayState'] || ''
if account_id
# Store in session and omniauth params for callback
request.session[:saml_account_id] = account_id
request.session[:saml_relay_state] = relay_state
# Keep SAML request context in OmniAuth env so the callback can be processed
# without depending on the Rails session cookie.
env['omniauth.params'] ||= {}
env['omniauth.params']['account_id'] = account_id
env['omniauth.params']['RelayState'] = relay_state
+26
View File
@@ -0,0 +1,26 @@
module Integrations::Openai::KeyValidator
TIMEOUT_SECONDS = 5
def self.valid?(api_key)
return false if api_key.blank?
connection = Faraday.new do |f|
f.options.timeout = TIMEOUT_SECONDS
f.options.open_timeout = TIMEOUT_SECONDS
end
response = connection.get("#{api_base}/models") do |req|
req.headers['Authorization'] = "Bearer #{api_key}"
end
response.status != 401
rescue Faraday::Error => e
Rails.logger.warn("[openai-key-validator] #{e.class}: #{e.message}")
true
end
def self.api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
"#{endpoint.chomp('/')}/v1"
end
end
+1 -1
View File
@@ -178,5 +178,5 @@
"scss-lint"
]
},
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
"packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001"
}
-1
View File
@@ -1 +0,0 @@
minimumReleaseAge: 10080
@@ -181,10 +181,6 @@ describe Messages::MessageBuilder do
end
context 'when custom email content is provided' do
before do
account.enable_features('quoted_email_reply')
end
it 'creates message with custom HTML email content' do
params = ActionController::Parameters.new({
content: 'Regular message content',
+2
View File
@@ -120,6 +120,8 @@ describe V2::ReportBuilder do
# Reopen 1 conversation
conversations.first.open!
end
create(:reporting_event, account: account, inbox: account.inboxes.first, conversation: nil, conversation_id: nil,
name: 'conversation_bot_handoff', created_at: Time.zone.today)
builder = described_class.new(account, params)
metrics = builder.timeseries
@@ -4,35 +4,99 @@ RSpec.describe V2::Reports::BotMetricsBuilder do
subject(:bot_metrics_builder) { described_class.new(inbox.account, params) }
let(:inbox) { create(:inbox) }
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let(:since) { 1.week.ago.to_i.to_s }
let(:until_time) { Time.now.to_i.to_s }
let(:params) { { since: since, until: until_time } }
before do
create(:agent_bot_inbox, inbox: inbox)
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id,
created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: unresolved_conversation.id, created_at: 2.days.ago)
end
describe '#metrics' do
context 'with valid params' do
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let!(:handoff_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
before do
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: handoff_conversation.id, created_at: 2.days.ago)
end
it 'returns correct metrics' do
metrics = bot_metrics_builder.metrics
expect(metrics[:conversation_count]).to eq(2)
expect(metrics[:message_count]).to eq(1)
expect(metrics[:resolution_rate]).to eq(50)
expect(metrics[:handoff_rate]).to eq(50)
end
end
context 'when a conversation has both bot_resolved and bot_handoff events in the same range' do
let!(:double_counted_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let!(:handoff_only_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
before do
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: handoff_only_conversation.id, created_at: 2.days.ago)
end
it 'excludes the conversation from resolution count — handoff wins' do
metrics = bot_metrics_builder.metrics
expect(metrics[:conversation_count]).to eq(2)
expect(metrics[:resolution_rate]).to eq(0)
expect(metrics[:handoff_rate]).to eq(100)
end
end
context 'when bot_resolved and bot_handoff are in different date ranges' do
let!(:multi_cycle_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
before do
# Bot resolved in current range
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
conversation_id: multi_cycle_conversation.id, created_at: 2.days.ago)
# Handoff happened before the range (in a previous cycle)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: multi_cycle_conversation.id, created_at: 2.weeks.ago)
end
it 'counts the resolution since the handoff is outside the range' do
metrics = bot_metrics_builder.metrics
expect(metrics[:conversation_count]).to eq(1)
expect(metrics[:resolution_rate]).to eq(100)
expect(metrics[:handoff_rate]).to eq(0)
end
end
context 'when a bot_handoff event has no conversation' do
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
before do
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account: inbox.account, inbox: inbox, conversation: nil, conversation_id: nil,
name: 'conversation_bot_handoff', created_at: 2.days.ago)
end
it 'does not exclude all bot resolutions' do
metrics = bot_metrics_builder.metrics
expect(metrics[:conversation_count]).to eq(1)
expect(metrics[:resolution_rate]).to eq(100)
expect(metrics[:handoff_rate]).to eq(0)
end
end
context 'with missing params' do
let(:params) { {} }
@@ -310,4 +310,43 @@ describe V2::Reports::Timeseries::ReportBuilder do
end
end
end
describe 'bot resolution counts' do
subject(:builder) { described_class.new(account, params) }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:current_time) { Time.current }
let(:params) do
{
type: 'account',
metric: 'bot_resolutions_count',
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
timezone_offset: nil,
group_by: 'day'
}
end
before do
travel_to current_time
resolved_conversation = create(:conversation, account: account, inbox: inbox)
double_counted_conversation = create(:conversation, account: account, inbox: inbox)
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: resolved_conversation,
created_at: current_time)
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: double_counted_conversation,
created_at: current_time)
create(:reporting_event, name: 'conversation_bot_handoff', account: account, conversation: double_counted_conversation,
created_at: current_time)
create(:reporting_event, name: 'conversation_bot_handoff', account: account, inbox: inbox, conversation: nil, conversation_id: nil,
created_at: current_time)
end
it 'excludes conversations that also had a bot handoff in the range' do
expect(builder.aggregate_value).to eq(1)
expect(builder.timeseries.sum { |row| row[:value] }).to eq(1)
end
end
end
@@ -34,6 +34,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
context 'when it is an authenticated user' do
let!(:agent) { create(:user, account: account, role: :agent) }
before do
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
end
it 'Ignores bulk_actions for wrong type' do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
@@ -202,6 +206,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
context 'when it is an authenticated user' do
let!(:agent) { create(:user, account: account, role: :agent) }
before do
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
end
it 'Bulk delete conversation labels' do
Conversation.first.add_labels(%w[support priority_customer])
Conversation.second.add_labels(%w[support priority_customer])
@@ -0,0 +1,84 @@
require 'rails_helper'
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/attachments', type: :request do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:inbox_1) { create(:inbox, account: account) }
let(:inbox_2) { create(:inbox, account: account) }
let(:contact_inbox_1) { create(:contact_inbox, contact: contact, inbox: inbox_1) }
let(:contact_inbox_2) { create(:contact_inbox, contact: contact, inbox: inbox_2) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:unknown) { create(:user, account: account, role: nil) }
before do
create(:inbox_member, user: agent, inbox: inbox_1)
conversation_1 = create(:conversation, account: account, inbox: inbox_1, contact: contact, contact_inbox: contact_inbox_1)
conversation_2 = create(:conversation, account: account, inbox: inbox_2, contact: contact, contact_inbox: contact_inbox_2)
create(:message, :with_attachment, conversation: conversation_1, account: account, inbox: inbox_1, message_type: 'incoming')
create(:message, :with_attachment, conversation: conversation_2, account: account, inbox: inbox_2, message_type: 'incoming')
end
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/attachments' do
context 'when unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user is logged in' do
context 'with user as administrator' do
it 'returns attachments from all the contact conversations' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].length).to eq 2
expect(json_response['meta']['total_count']).to eq 2
end
it 'serialises the conversation display id as conversation_id' do
conversation = contact.conversations.first
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: admin.create_new_auth_token
payload = response.parsed_body['payload']
attachment = payload.find { |a| a['conversation_id'] == conversation.display_id }
expect(attachment).not_to be_nil
expect(attachment).to include('id', 'message_id', 'data_url', 'file_type', 'created_at', 'sender')
end
end
context 'with user as agent' do
it 'returns attachments only from inboxes the agent has access to' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].length).to eq 1
expect(json_response['meta']['total_count']).to eq 1
end
end
context 'with user as unknown role' do
it 'returns no attachments' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: unknown.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']).to be_empty
end
end
end
end
end
@@ -1008,6 +1008,19 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'does not allow binding an agent bot from another account' do
other_account = create(:account)
foreign_bot = create(:agent_bot, account: other_account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: admin.create_new_auth_token,
params: { agent_bot: foreign_bot.id },
as: :json
expect(response).to have_http_status(:not_found)
expect(inbox.reload.agent_bot).to be_nil
end
end
end
@@ -3,6 +3,8 @@ require 'rails_helper'
RSpec.describe 'Integration Apps API', type: :request do
let(:account) { create(:account) }
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
describe 'GET /api/v1/integrations/apps' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
@@ -101,6 +101,20 @@ RSpec.describe 'Notifications API', type: :request do
expect(response).to have_http_status(:success)
expect(notification.reload.read_at).not_to eq('')
end
it 'does not update a notification reached via a different account that the user belongs to' do
other_account = create(:account)
create(:account_user, account: other_account, user: admin, role: :administrator)
original_read_at = notification.read_at
patch "/api/v1/accounts/#{other_account.id}/notifications/#{notification.id}",
headers: admin.create_new_auth_token,
params: { read_at: true },
as: :json
expect(response).to have_http_status(:not_found)
expect(notification.reload.read_at).to eq(original_read_at)
end
end
end
@@ -227,7 +241,7 @@ RSpec.describe 'Notifications API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes all the read notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :read)
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :read)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
@@ -238,7 +252,7 @@ RSpec.describe 'Notifications API', type: :request do
end
it 'deletes all the notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :all)
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :all)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
@@ -100,6 +100,41 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
expect(json_response['name']).to eql('test_portal')
expect(json_response['custom_domain']).to eql('support.chatwoot.dev')
end
it 'creates portal when custom_domain is omitted from request body' do
portal_params = {
portal: {
name: 'test_portal_no_domain',
slug: 'test_kbase_no_domain'
}
}
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eql('test_portal_no_domain')
expect(json_response['custom_domain']).to be_nil
end
it 'creates portal when custom_domain is blank' do
portal_params = {
portal: {
name: 'test_portal_blank_domain',
slug: 'test_kbase_blank_domain',
custom_domain: ''
}
}
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eql('test_portal_blank_domain')
expect(json_response['custom_domain']).to be_blank
end
end
end
@@ -192,6 +227,21 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
expect(portal.reload.logo).to be_attached
end
it 'does not allow associating an inbox from another account' do
other_account = create(:account)
foreign_inbox = create(:inbox, account: other_account)
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: {
portal: { name: portal.name },
inbox_id: foreign_inbox.id
},
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
expect(portal.reload.channel_web_widget_id).to be_nil
end
it 'clears associated web widget when inbox selection is blank' do
web_widget_inbox = create(:inbox, account: account)
portal.update!(channel_web_widget: web_widget_inbox.channel)
@@ -106,6 +106,21 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
expect(response).to have_http_status(:success)
expect { subscription.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
it 'does not delete another user notification subscription with the same push token' do
victim = create(:user, account: account, role: :agent)
victim_subscription = create(:notification_subscription, subscription_type: 'fcm',
subscription_attributes: { push_token: 'victimToken' },
user: victim)
delete '/api/v1/notification_subscriptions',
params: { push_token: 'victimToken' },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect { victim_subscription.reload }.not_to raise_error
end
end
end
end
@@ -212,6 +212,26 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
end
describe 'PUT /api/v1/widget/messages' do
context 'when put request targets a message from another visitor in the same inbox' do
it 'does not update the foreign message' do
other_contact = create(:contact, account: account, email: nil)
other_contact_inbox = create(:contact_inbox, contact: other_contact, inbox: web_widget.inbox)
other_conversation = create(:conversation, contact: other_contact, account: account,
inbox: web_widget.inbox, contact_inbox: other_contact_inbox)
foreign_message = create(:message, content_type: 'input_email', account: account,
inbox: web_widget.inbox, conversation: other_conversation)
original_email = foreign_message.submitted_email
put api_v1_widget_message_url(foreign_message.id),
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:not_found)
expect(foreign_message.reload.submitted_email).to eq(original_email)
end
end
context 'when put request is made with non existing email' do
it 'updates message in conversation and creates a new contact' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
@@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
end
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
upn = 'livetestaccount@capeunionmart.co.za'
mailbox = 'LiveTestAccount@cumi.co.za'
response_body = {
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, false),
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
}
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code, state: state }
channel = account.inboxes.last.channel
expect(channel.imap_login).to eq upn
expect(channel.email).to eq mailbox
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox
expect(inbox.channel.provider_config).to eq({})
@@ -16,6 +16,19 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
data = response.parsed_body
expect(data.length).to eq 2
end
it 'does not return messages from a conversation in another inbox even when both share the same contact' do
other_channel = create(:channel_api, account: conversation.account)
other_contact_inbox = create(:contact_inbox, contact: contact, inbox: other_channel.inbox)
foreign_conversation = create(:conversation, contact: contact, account: conversation.account,
inbox: other_channel.inbox, contact_inbox: other_contact_inbox)
create(:message, account: foreign_conversation.account, inbox: foreign_conversation.inbox, conversation: foreign_conversation)
get "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/" \
"#{foreign_conversation.display_id}/messages"
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /public/api/v1/inboxes/{identifier}/contact/{source_id}/conversations/{conversation_id}/messages' do
@@ -38,10 +38,24 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(super_admin_settings_path)
expect(flash[:notice]).to be_present
expect(flash[:alert]).to be_blank
expect(flash[:success]).to be_blank
config = GlobalConfig.get('FB_APP_ID')
expect(config['FB_APP_ID']).to eq('FB_APP_ID')
end
it 'asks admins to restart web and worker processes for runtime config changes' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/app_config?config=captain', params: { app_config: { CAPTAIN_OPEN_AI_ENDPOINT: 'https://api.openai.com' } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(super_admin_settings_path)
expect(flash[:success]).to be_present
expect(flash[:alert]).to be_blank
expect(flash[:notice]).to be_blank
end
end
end
end
@@ -39,4 +39,34 @@ RSpec.describe 'Super Admin Installation Config API', type: :request do
end
end
end
describe 'PATCH /super_admin/installation_configs/:id' do
context 'when it is an authenticated super admin' do
it 'shows a regular success notice for config that does not require restart' do
sign_in(super_admin, scope: :super_admin)
config = create(:installation_config, name: 'TESTCONFIG', value: 'TESTVALUE', locked: false)
patch "/super_admin/installation_configs/#{config.id}", params: {
installation_config: { name: config.name, value: 'UPDATEDVALUE' }
}
expect(response).to have_http_status(:found)
expect(flash[:notice]).to be_present
expect(flash[:success]).to be_blank
end
it 'shows a restart success notice for runtime config changes' do
sign_in(super_admin, scope: :super_admin)
config = create(:installation_config, name: 'OTEL_PROVIDER', value: 'langfuse', locked: false)
patch "/super_admin/installation_configs/#{config.id}", params: {
installation_config: { name: config.name, value: 'langfuse' }
}
expect(response).to have_http_status(:found)
expect(flash[:success]).to be_present
expect(flash[:notice]).to be_blank
end
end
end
end
@@ -8,5 +8,10 @@ describe '/swagger', type: :request do
expect(response.body).to include('redoc')
expect(response.body).to include('/swagger.json')
end
it 'does not render files outside the swagger directory' do
get '/swagger/%2Fetc%2Fpasswd'
expect(response).to have_http_status(:not_found)
end
end
end
@@ -46,7 +46,8 @@ RSpec.describe 'Company contacts API', type: :request do
describe 'POST /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
it 'links an existing contact to the company' do
contact = create(:contact, name: 'Jane Contact', account: account, additional_attributes: { 'city' => 'Berlin' })
contact = create(:contact, name: 'Jane Contact', account: account, last_activity_at: 1.hour.ago,
additional_attributes: { 'city' => 'Berlin' })
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
params: { contact_id: contact.id },
@@ -58,6 +59,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
end
end
@@ -29,11 +29,6 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
get "/omniauth/saml/callback?account_id=#{account.id}"
# expect a 302 redirect to auth/saml/callback
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
# expect redirect to login with SSO token
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# verify user was created
@@ -50,14 +45,21 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
get "/omniauth/saml/callback?account_id=#{account.id}"
# expect a 302 redirect to auth/saml/callback
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
end
end
it 'redirects mobile SAML login to the mobile deep link' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'mobile@example.com', account: account)
set_saml_config('mobile@example.com')
get "/omniauth/saml/callback?account_id=#{account.id}&RelayState=mobile"
expect(response).to redirect_to(%r{\Achatwootapp://auth/saml\?email=.+&sso_auth_token=.+\z})
end
end
it 'rejects an existing user from another account' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
other_account = create(:account)
@@ -66,9 +68,6 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
get "/omniauth/saml/callback?account_id=#{account.id}"
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
expect(existing_user.reload.provider).to eq('email')
expect(existing_user.accounts).not_to include(account)
@@ -15,6 +15,7 @@ RSpec.describe Captain::ConversationCompletionService do
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
describe '#perform' do
+11
View File
@@ -35,4 +35,15 @@ RSpec.describe Company, type: :model do
end
end
end
describe '#record_activity_at!' do
it 'does not move company activity backwards' do
company = create(:company, last_activity_at: Time.zone.now)
original_activity_at = company.last_activity_at
company.record_activity_at!(1.hour.ago)
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
end
end
end
@@ -43,6 +43,15 @@ RSpec.describe Contact, type: :model do
contact.reload
expect(contact.company).to eq(existing_company)
end
it 'updates company activity when contact activity changes' do
company = create(:company, account: account)
contact = create(:contact, account: account, company: company)
contact.update!(last_activity_at: Time.zone.now)
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
end
end
context 'when multiple contacts share the same domain' do
@@ -7,7 +7,8 @@ end
RSpec.describe Enterprise::Onboarding::WebWidgetCreationService do
let(:account) do
create(:account, name: 'Acme Inc', domain: 'acme.com', custom_attributes: {
create(:account, name: 'Acme Inc', custom_attributes: {
'website' => 'acme.com',
'brand_info' => { 'slogan' => 'Fallback slogan', 'description' => 'Fallback description' }
})
end
+29 -19
View File
@@ -1,12 +1,6 @@
require 'rails_helper'
RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed' },
ids: Conversation.first(3).pluck(:display_id)
}
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
let(:account) { create(:account) }
@@ -14,9 +8,11 @@ RSpec.describe BulkActionsJob do
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
let(:conversation_ids) { [conversation_1.display_id, conversation_2.display_id, conversation_3.display_id] }
let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } }
before do
Conversation.all.find_each do |conversation|
[conversation_1, conversation_2, conversation_3].each do |conversation|
create(:inbox_member, inbox: conversation.inbox, user: agent)
end
end
@@ -38,10 +34,10 @@ RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.status).to eq('open')
expect(conversation_1.status).to eq('open')
described_class.perform_now(account: account, params: params, user: agent)
@@ -54,32 +50,46 @@ RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.assignee_id).to be_nil
expect(conversation_1.assignee_id).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.assignee_id).to eq(agent.id)
expect(Conversation.second.assignee_id).to eq(agent.id)
expect(Conversation.third.assignee_id).to eq(agent.id)
expect(conversation_1.reload.assignee_id).to eq(agent.id)
expect(conversation_2.reload.assignee_id).to eq(agent.id)
expect(conversation_3.reload.assignee_id).to eq(agent.id)
end
it 'bulk updates the snoozed_until' do
params = {
type: 'Conversation',
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.snoozed_until).to be_nil
expect(conversation_1.snoozed_until).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.snoozed_until).to be_present
expect(Conversation.second.snoozed_until).to be_present
expect(Conversation.third.snoozed_until).to be_present
expect(conversation_1.reload.snoozed_until).to be_present
expect(conversation_2.reload.snoozed_until).to be_present
expect(conversation_3.reload.snoozed_until).to be_present
end
it 'skips conversations whose inbox the agent does not belong to' do
forbidden_conversation = create(:conversation, account_id: account.id, status: :open)
params = {
type: 'Conversation',
fields: { status: 'resolved' },
ids: [conversation_1.display_id, forbidden_conversation.display_id]
}
described_class.perform_now(account: account, params: params, user: agent)
expect(conversation_1.reload.status).to eq('resolved')
expect(forbidden_conversation.reload.status).to eq('open')
end
end
end
@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe Migration::ValidateOpenaiHooksJob do
let(:integrations_mailer) { instance_double(AdministratorNotifications::IntegrationsNotificationMailer) }
let(:mailer_response) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) }
before do
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer)
allow(integrations_mailer).to receive(:openai_disconnect).and_return(mailer_response)
end
def create_openai_hook(account:, api_key: 'sk-good')
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => api_key })
end
it 'destroys invalid hooks, preserves valid ones, sends disconnect email, and reports stats' do
account_a = create(:account)
account_b = create(:account)
valid_hook = create_openai_hook(account: account_a, api_key: 'sk-good')
invalid_hook = create_openai_hook(account: account_b, api_key: 'sk-bad')
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
result = described_class.perform_now
expect(valid_hook.reload).to be_enabled
expect { invalid_hook.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: account_b)
expect(result).to eq(checked: 2, destroyed: 1)
end
it 'scopes to a specific account when provided' do
account_a = create(:account)
account_b = create(:account)
hook_a = create_openai_hook(account: account_a, api_key: 'sk-bad')
hook_b = create_openai_hook(account: account_b, api_key: 'sk-bad')
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
described_class.perform_now(account: account_a)
expect { hook_a.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(hook_b.reload).to be_enabled
end
it 'only checks enabled OpenAI hooks' do
account = create(:account)
slack_hook = create(:integrations_hook, account: account, app_id: 'slack')
disabled_hook = create_openai_hook(account: account)
disabled_hook.disable
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
described_class.perform_now
expect(slack_hook.reload).to be_enabled
expect(disabled_hook.reload).to be_disabled # still disabled, not re-checked
end
end
@@ -2,37 +2,45 @@ require 'rails_helper'
RSpec.describe Notification::DeleteNotificationJob do
let(:user) { create(:user) }
let(:account) { create(:account) }
let(:conversation) { create(:conversation) }
context 'when enqueuing the job' do
it 'enqueues the job to delete all notifications' do
expect do
described_class.perform_later(user.id, type: :all)
described_class.perform_later(user, account, type: :all)
end.to have_enqueued_job(described_class).on_queue('low')
end
it 'enqueues the job to delete read notifications' do
expect do
described_class.perform_later(user.id, type: :read)
described_class.perform_later(user, account, type: :read)
end.to have_enqueued_job(described_class).on_queue('low')
end
end
context 'when performing the job' do
let(:other_account) { create(:account) }
before do
create(:notification, user: user, read_at: nil)
create(:notification, user: user, read_at: Time.current)
create(:notification, account: account, user: user, read_at: nil)
create(:notification, account: account, user: user, read_at: Time.current)
create(:notification, account: other_account, user: user, read_at: Time.current)
end
it 'deletes all notifications' do
described_class.perform_now(user, type: :all)
expect(user.notifications.count).to eq(0)
it 'deletes all notifications for the requested account' do
described_class.perform_now(user, account, type: :all)
expect(user.notifications.where(account_id: account.id).count).to eq(0)
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
end
it 'deletes only read notifications' do
described_class.perform_now(user, type: :read)
expect(user.notifications.count).to eq(1)
expect(user.notifications.where(read_at: nil).count).to eq(1)
it 'deletes only read notifications for the requested account' do
described_class.perform_now(user, account, type: :read)
expect(user.notifications.where(account_id: account.id).count).to eq(1)
expect(user.notifications.where(account_id: account.id, read_at: nil).count).to eq(1)
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
end
end
end
@@ -26,6 +26,7 @@ RSpec.describe Captain::BaseTaskService do
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
describe '#perform' do
@@ -10,6 +10,8 @@ RSpec.describe Integrations::LlmBaseService do
let(:error) { StandardError.new('API Error') }
let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json }
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
describe '#make_api_call' do
before do
allow(service).to receive(:instrument_llm_call).and_yield
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Integrations::Openai::KeyValidator do
let(:api_key) { 'sk-test-valid-key-123456789' }
let(:probe_url) { 'https://api.openai.com/v1/models' }
it 'accepts keys that OpenAI recognizes' do
stub_request(:get, probe_url).to_return(status: 200)
expect(described_class.valid?(api_key)).to be true
end
it 'rejects keys that OpenAI does not recognize' do
stub_request(:get, probe_url).to_return(status: 401)
expect(described_class.valid?(api_key)).to be false
end
it 'rejects blank keys without making a network call' do
expect(described_class.valid?(nil)).to be false
expect(described_class.valid?('')).to be false
end
it 'treats transient failures as valid to avoid blocking saves' do
stub_request(:get, probe_url).to_return(status: 500)
expect(described_class.valid?(api_key)).to be true
stub_request(:get, probe_url).to_timeout
expect(described_class.valid?(api_key)).to be true
end
it 'routes the probe through the configured endpoint' do
custom_url = 'https://proxy.example.com/v1/models'
allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_ENDPOINT')
.and_return(instance_double(InstallationConfig, value: 'https://proxy.example.com/'))
stub_request(:get, custom_url).to_return(status: 200)
described_class.valid?(api_key)
expect(WebMock).to have_requested(:get, custom_url)
expect(WebMock).not_to have_requested(:get, probe_url)
end
end
+62 -46
View File
@@ -144,56 +144,66 @@ RSpec.describe SafeFetch do
context 'with URL validation' do
it 'raises InvalidUrlError for javascript: URLs' do
expect { described_class.fetch('javascript:alert(1)') { nil } }
.to raise_error(described_class::InvalidUrlError)
expect { described_class.fetch('javascript:alert(1)') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for mailto: URLs' do
expect { described_class.fetch('mailto:test@example.com') { nil } }
.to raise_error(described_class::InvalidUrlError)
expect { described_class.fetch('mailto:test@example.com') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for data: URLs' do
expect { described_class.fetch('data:text/html,<x>') { nil } }
.to raise_error(described_class::InvalidUrlError)
expect { described_class.fetch('data:text/html,<x>') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for ftp: URLs' do
expect { described_class.fetch('ftp://example.com/file') { nil } }
.to raise_error(described_class::InvalidUrlError)
expect { described_class.fetch('ftp://example.com/file') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for malformed URLs' do
expect { described_class.fetch('not_a_url') { nil } }
.to raise_error(described_class::InvalidUrlError)
expect { described_class.fetch('not_a_url') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError when host is missing' do
expect { described_class.fetch('http:///path') { nil } }
.to raise_error(described_class::InvalidUrlError, /missing host/)
expect { described_class.fetch('http:///path') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
end
context 'with SSRF protection (integration with ssrf_filter)' do
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
.to raise_error(described_class::UnsafeUrlError)
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError for loopback addresses' do
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
.to raise_error(described_class::UnsafeUrlError)
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
.to raise_error(described_class::UnsafeUrlError)
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
.to raise_error(described_class::UnsafeUrlError)
expect { described_class.fetch('http://evil.example.com/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
end
@@ -205,8 +215,9 @@ RSpec.describe SafeFetch do
headers: { 'Content-Type' => 'text/html' }
)
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::UnsupportedContentTypeError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'rejects application/octet-stream responses' do
@@ -216,8 +227,9 @@ RSpec.describe SafeFetch do
headers: { 'Content-Type' => 'application/octet-stream' }
)
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::UnsupportedContentTypeError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'allows video/mp4 responses' do
@@ -266,20 +278,18 @@ RSpec.describe SafeFetch do
headers: { 'Content-Type' => 'image/webp' }
)
expect do
described_class.fetch(
url,
allowed_content_type_prefixes: [],
allowed_content_types: ['image/png']
) { nil }
end.to raise_error(described_class::UnsupportedContentTypeError)
expect { described_class.fetch(url, allowed_content_type_prefixes: [], allowed_content_types: ['image/png']) { nil } }
.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'rejects when the content-type header is missing' do
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::UnsupportedContentTypeError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
end
@@ -347,8 +357,9 @@ RSpec.describe SafeFetch do
end
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
expect { described_class.fetch(url, method: :options) { nil } }
.to raise_error(described_class::UnsupportedMethodError)
expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError')
end
end
end
@@ -360,8 +371,9 @@ RSpec.describe SafeFetch do
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url, max_bytes: 2) { nil } }
.to raise_error(described_class::FileTooLargeError)
expect { described_class.fetch(url, max_bytes: 2) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
end
end
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
@@ -375,8 +387,9 @@ RSpec.describe SafeFetch do
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::FileTooLargeError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
end
end
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
@@ -414,24 +427,27 @@ RSpec.describe SafeFetch do
it 'maps Net::ReadTimeout to FetchError' do
stub_request(:get, url).to_raise(Net::ReadTimeout)
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::FetchError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FetchError')
end
end
it 'maps SocketError to FetchError' do
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::FetchError)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FetchError')
end
end
end
context 'with non-2xx upstream responses' do
it 'raises HttpError with the status code in the message' do
it 'raises HttpError on non-2xx responses' do
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
expect { described_class.fetch(url) { nil } }
.to raise_error(described_class::HttpError, /404/)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::HttpError')
end
end
end
end
@@ -39,4 +39,20 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
describe 'openai_disconnect' do
let(:mail) { described_class.with(account: account).openai_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your OpenAI integration was disconnected')
end
it 'renders the content' do
expect(mail.body.encoded).to include('the configured API key is invalid or revoked')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end
+2
View File
@@ -5,6 +5,8 @@ RSpec.describe Integrations::App do
let(:app) { apps.find(id: app_name) }
let(:account) { create(:account) }
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
describe '#name' do
let(:app_name) { 'slack' }

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