Compare commits

...
Author SHA1 Message Date
Shivam Mishra 29c89d5634 fix: linear hover 2025-04-23 13:18:25 +05:30
Muhsin KelothandGitHub 76bec9a3ff fix: Add missing 'hc' path segment after custom domain in article URLs (#11353) 2025-04-22 21:53:50 +05:30
Muhsin KelothandGitHub e3bacd27d8 feat: Add custom domain to article URL if custom domain exists for the portal (#11349)
Portals can have custom domains. When inserting or previewing articles,
we consider the frontend URL. This PR fixes article URL generation to
properly include the portal's custom domain.
2025-04-22 20:54:09 +05:30
Sivin VargheseandGitHub 9c711bab74 fix: Inconsistent widget bubble focus outline shape (#11345)
# Pull Request Template

## Description

This PR resolves the issue where the focus outline for the standard
bubble type appears as a square in Safari, while it appears circular in
Chrome.

Fixes
[CW-4252](https://linear.app/chatwoot/issue/CW-4252/v41-circle-around-campaign-pop-up-warped),
https://github.com/chatwoot/chatwoot/issues/11327

**Cause**
In Chrome, the focus outline for the standard bubble type is circular,
but in Safari, it appears square. This is due to a 20px margin on the
SVG inside the bubble.

**Solution**
The `20px` margin was removed from the SVG, fixing the focus outline to
appear circular in Safari browser.

## Type of change

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

## How Has This Been Tested?

### Loom Video

https://www.loom.com/share/cc4244f369f84b98afaef539b79abcfe?sid=e957df16-1a53-4349-8bdc-705b2ed82d45

### Screenshots
**Before** 

https://www.loom.com/share/e858417722c64df6801ea87e4b89779f?sid=81a0acec-c5f0-4daa-832c-1f23289e2352
<img width="100" alt="image"
src="https://github.com/user-attachments/assets/19c6b62c-96da-485b-9fe8-223065415369"
/>

**After**

https://www.loom.com/share/3946546382884a33a8fef89f81faf7c2?sid=feeaf18c-2b3d-4d4f-bcdf-70335b456dd1
<img width="100" alt="image"
src="https://github.com/user-attachments/assets/0e83d9e3-3153-47ef-9f2d-0a2f7270935c"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
2025-04-21 16:53:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5ccb131a67 chore(deps-dev): Bump vite from 5.4.17 to 5.4.18 (#11324)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite)
from 5.4.17 to 5.4.18.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.18</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.18 (2025-04-10)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19830">#19830</a>,
reject requests with <code>#</code> in request-target (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19831">#19831</a>)
(<a
href="https://github.com/vitejs/vite/commit/823675baff2bd6809c74ba2d9acca0327923a54f">823675b</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/19830">#19830</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/19831">#19831</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/731b77d19d36f5682a5441b49cb2f6473389ad99"><code>731b77d</code></a>
release: v5.4.18</li>
<li><a
href="https://github.com/vitejs/vite/commit/823675baff2bd6809c74ba2d9acca0327923a54f"><code>823675b</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19830">#19830</a>,
reject requests with <code>#</code> in request-target (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19831">#19831</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v5.4.18/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.17&new-version=5.4.18)](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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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>
2025-04-21 16:43:46 -07:00
cce1b874c1 chore: update EE LICENCE year (#11344)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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.


## Checklist:

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

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-04-21 15:29:55 +05:30
Muhsin KelothandGitHub 0a394e16ca chore: Audit message characters across all channels (#11343)
- Audited message characters across all channels.
- Replaced `isAInstagramChannel` with `isAnInstagramChannel`
2025-04-21 14:46:51 +05:30
1531772365 chore: Migrated Instagram inbox warning style issues (#11332)
**Before**
![CleanShot 2025-04-17 at 22 53
28@2x](https://github.com/user-attachments/assets/00841dd1-ca1f-47b3-9972-6c9100bd2291)

![CleanShot 2025-04-17 at 22 53
46@2x](https://github.com/user-attachments/assets/648a4770-3aea-4b84-9456-6941744a4d65)


**After**
![CleanShot 2025-04-17 at 22 50
40@2x](https://github.com/user-attachments/assets/d37250fd-5400-4548-82f4-ab0c3b417b3a)
![CleanShot 2025-04-17 at 22 50
47@2x](https://github.com/user-attachments/assets/ca611297-8dd0-4bd9-b6b6-02660c252aa9)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-04-21 14:17:39 +05:30
Sivin VargheseandGitHub c24a6dc74c fix: Prevent CC/BCC field reset on chat activity actions (#11342)
# Pull Request Template

## Description

This PR fixes the CC/BCC field reset issue on activity action.
Fixes
[CW-4256](https://linear.app/chatwoot/issue/CW-4256/emails-added-in-cc-and-bcc-disappears-when-you-click-on-assign-to-me),
#5234

### Cause of the Issue
Previously, the CC and BCC fields in the reply box were being reset
whenever a conversation activity occurred (such as assignment, status
change, etc.). This happened because watchers on the current chat
messages array and current chat object would trigger
`setCCAndToEmailsFromLastChat` even when the last message was an
activity or system message, not a real email.

### Solution

- The updated logic ensures that the CC and BCC fields are only set
under the following conditions:
   **1**. Switching to a new conversation.
**2**. In the same conversation, only if the last message is not an
activity (i.e., only for actual emails).

- It uses the `lastEmail` computed property, which filters out private
and activity messages, ensuring only real email context changes update
the fields.
- This prevents user-entered `CC/BCC` values from being cleared after
system events (e.g., assignments or status changes), while still
updating the fields correctly when relevant messages are received.

## Type of change

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

## How Has This Been Tested?

### Loom video
**Before**

https://www.loom.com/share/2ec50dd1c0ed4eaf9170465274bea41e?sid=cc8b88cb-fd39-473a-8df3-78a242c8407b

**After**

https://www.loom.com/share/17fd2d96d5d84a049dcbf20d401d2ada?sid=8949ad48-7769-49d2-92c5-267da8c60d6e



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-21 13:48:56 +05:30
aeef091084 fix: correct typo in CampaignConversationBuilder (#11336)
## Description

Fixed a typo in the `CampaignConversationBuilder` class.

## Type of change

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

## How Has This Been Tested?

Verified that the typo fix does not affect functionality by running the
existing test suite.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-04-21 11:57:38 +05:30
Muhsin KelothandGitHub 97895db85e chore: Disable warnings old Instagram inbox for messenger conversations (#11329)
We have added warnings for existing Instagram messenger inboxes via
https://github.com/chatwoot/chatwoot/pull/11303. However, an issue arose
where warnings were incorrectly displaying for messenger conversations.
This PR resolves that issue.
2025-04-17 15:47:56 +05:30
Muhsin KelothandGitHub ce55e80bb4 chore: Add the support for xml file in attachment (#11328)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-17 14:31:52 +05:30
Sojan ea14f99ae1 Merge branch 'release/4.1.0' into develop 2025-04-16 23:10:53 -07:00
Sojan 59e0499616 Bump version to 4.1.0 2025-04-16 23:10:13 -07:00
Sojan JoseandGitHub de8fdb1377 chore: Move Instagram channel feature to GA (#11323)
- Move instagram channel from  `internal` to  general availability
2025-04-17 11:23:45 +05:30
Sojan JoseandGitHub fe20b61bc4 chore: Enable chatwoot_v4 feature flag by default for accounts (#11321)
- Updates ACCOUNT_LEVEL_FEATURE_DEFAULTS installation config to enable
chatwoot_v4
- Enables chatwoot_v4 for all existing accounts in batches of 100
- Clears GlobalConfig cache after update
2025-04-17 08:53:37 +05:30
Sojan JoseandGitHub 49715ebf2b chore: deprecate report_v4 feature flag and remove gating logic (#11320)
- deprecated report_v4 feature flag
- remove the gating logic so new reports is available by default
- make search_with_gin an internal feature flag
2025-04-17 08:52:56 +05:30
0b156f6d4e fix: Handle Instagram text and attachments as separate messages (#11315)
When we send text with attachments on Instagram, Instagram treats text
and attachments as separate messages. However, Chatwoot keeps them as a
single message. Since Instagram sends echo events for each message, this
can create duplicate messages in Chatwoot. To prevent this, we will send
text and attachments as separate messages.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-04-16 20:46:47 +05:30
630826baed feat: Implement UI for Agent Bots in settings and remove CSML support (#11276)
- Add agent bots management UI in settings with avatar upload
- Enable agent bot configuration for all inbox types
- Implement proper CRUD operations with webhook URL support
- Fix agent bots menu item visibility in settings sidebar
- Remove all CSML-related code and features
- Add migration to convert existing CSML bots to webhook bots
- Simplify agent bot model and services to focus on webhook bots
- Improve UI to differentiate between system bots and account bots

## Video 





https://github.com/user-attachments/assets/3f4edbb7-b758-468c-8dd6-a9537b983f7d

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-04-16 18:02:49 +05:30
Sivin VargheseandGitHub 72509f9e38 chore: Improve translation service with HTML and plain text support (#11305)
# Pull Request Template

## Description

This PR changes to translation to properly handle different content
types during translation.

### Changes

1. **Email translation with HTML support**
   - Properly detects and preserves HTML content from emails
   - Sets `mime_type` to 'text/html' when HTML content is present

2. **Email translation with plain text support**
   - Falls back to email text content when HTML is not available
- Sets `mime_type` to 'text/plain' when HTML is not available and
content type includes 'text/plain'

3. **Plain message with plain text support (Non email channels)**
   - Sets `mime_type` to 'text/plain' for non-email channels
- Fixes an issue where Markdown formatting was being lost due to
incorrect `mime_type`
   
**Note**: Translation for very long emails is not currently supported.

Fixes
https://linear.app/chatwoot/issue/CW-4244/translate-button-doesnt-work-in-email-channels

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/8f8428ed2cfe415ea5cb6c547c070f00?sid=eab9fa11-05f8-4838-9181-334bee1023c4


## 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
2025-04-16 17:59:06 +05:30
Muhsin KelothandGitHub c6d4bc5632 fix: Old Instagram inbox warnings (#11318)
We have added warnings for existing Instagram messenger inboxes via
https://github.com/chatwoot/chatwoot/pull/11303. There is an issue with
finding the correct Instagram/messenger inbox. This PR will fixes that
issue.
2025-04-16 16:30:32 +05:30
Shivam MishraandGitHub 5c00880857 fix: email expand color (#11314)
#### Before

![CleanShot 2025-04-16 at 12 40
57@2x](https://github.com/user-attachments/assets/da3a9acc-c7a9-496f-96b7-74e02cb479f4)

![CleanShot 2025-04-16 at 12 42
00@2x](https://github.com/user-attachments/assets/55cf40aa-1867-4062-acee-7d95b7d4a0bc)


#### After

![CleanShot 2025-04-16 at 12 40
36@2x](https://github.com/user-attachments/assets/a8ec4b03-a501-4f56-be7a-bddf7912ba1b)

![CleanShot 2025-04-16 at 12 42
16@2x](https://github.com/user-attachments/assets/fe47f492-69ab-4a0c-8dd7-979485742d9b)
2025-04-16 13:13:07 +05:30
Sivin VargheseandGitHub 4da92bbc12 chore: Update PDF file text color from ruby to slate (#11313)
# Pull Request Template

## Description

This PR includes updating the PDF text color from Ruby to Slate for
improved readability

## How Has This Been Tested?

### Screenshots
**Before**

| Dark Mode | Light Mode |
| ------------- | ------------- |
| <img width="258" alt="image"
src="https://github.com/user-attachments/assets/6124c69d-8bf1-47f3-9a06-eeb21dbedbb2"
/> | <img width="258" alt="image"
src="https://github.com/user-attachments/assets/d0f2b90b-6796-45ae-a02a-05c07246d25c"
/> |


**After**

| Dark Mode | Light Mode |
| ------------- | ------------- |
| <img width="258" alt="image"
src="https://github.com/user-attachments/assets/c0285062-83b6-49d5-bdb0-2aebf977cf74"
/> | <img width="258" alt="image"
src="https://github.com/user-attachments/assets/6b153d45-ee8d-42ac-a863-0fa8b294a21b"
/> |


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
2025-04-16 13:05:36 +05:30
Muhsin KelothandGitHub ad17887898 feat: Add warnings for existing instagram messenger channels (#11303) 2025-04-16 12:54:41 +05:30
Sivin VargheseandGitHub 458bf1803e fix: Emoji rendering issue with <textarea/> in chrome (#11312)
# Pull Request Template

## Description

This PR resolves a Chrome-specific rendering bug where emojis in the
`<textarea>` of the reply box display a shadow or different color tone
when placed at the start of the input without a leading space.

**Solution:** Removed the border-radius from the textarea in the Reply
box component, which resolves the rendering issue in Chrome

**Cause:** This appears to be a Chrome rendering bug related to how
border-radius is handled on form elements, especially with emojis. The
exact cause is unclear, but there is some known issue
[Chromium Issue 40333458](https://issues.chromium.org/issues/40333458)
[Related Chromium
Duplicates](https://issues.chromium.org/issues/40333458/dupes)

## Type of change

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

## How Has This Been Tested?

### Screen recording

**Before**


https://github.com/user-attachments/assets/618de129-5631-4c7c-ab0b-7188b83c6bf1

**After**


https://github.com/user-attachments/assets/bb3adcbe-e603-4792-a8fd-51501d284c78





## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
2025-04-16 12:21:28 +05:30
ebef021e94 chore: Increase the timeout to support slow SMTP servers (#10318)
Converting
https://github.com/chatwoot/chatwoot/issues/7802#issuecomment-2171005257
to a PR

Co-authored-by: Nickson Yap <hi@nickson.me>
2025-04-16 12:15:49 +05:30
3da26ee025 feat: Move email attachments from links to file attachments (#11304)
Add ability to send files as attachments instead of links
Fixes: https://github.com/chatwoot/chatwoot/issues/1074


## Changes 
- `emaily_reply` : We will attach the small attachments as attachments
and large ones as links
- `reply_with_summary`, `conversation_transcript`,
`reply_with_out_summary` : We will change the attachment format to the
following instead of the previous `View the attachment here`

```
Attachments:
file_name
file_name2
```


---------

ref: https://github.com/chatwoot/chatwoot/pull/10318/files -> for fixing
: https://github.com/chatwoot/chatwoot/pull/9655#issuecomment-2183962550

---------

Co-authored-by: Marco Marinho <marcomarinho12@gmail.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-04-15 23:43:12 -07:00
89 changed files with 1476 additions and 1288 deletions
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock!
# We won't send campaigns if a conversation is already present
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
end
def process_avatar_from_url
+2 -1
View File
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end
@@ -65,6 +65,7 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
+17
View File
@@ -1,9 +1,26 @@
/* global axios */
import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient {
constructor() {
super('agent_bots', { accountScoped: true });
}
create(data) {
return axios.post(this.url, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
}
export default new AgentBotsAPI();
@@ -125,7 +125,10 @@ defineExpose({ open, close });
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div class="flex items-center justify-between w-full gap-3">
<div
v-if="showCancelButton || showConfirmButton"
class="flex items-center justify-between w-full gap-3"
>
<Button
v-if="showCancelButton"
variant="faded"
@@ -19,7 +19,7 @@ const {
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
isAnInstagramChannel,
} = useInbox();
const {
@@ -60,7 +60,7 @@ const isSent = computed(() => {
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAInstagramChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -100,7 +100,7 @@ const isRead = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAInstagramChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -0,0 +1,24 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
defineEmits(['toggle']);
</script>
<template>
<span>
<span
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
@click="$emit('toggle')"
>
{{
showingOriginal
? $t('CONVERSATION.VIEW_TRANSLATED')
: $t('CONVERSATION.VIEW_ORIGINAL')
}}
</span>
</span>
</template>
@@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
useMessageContext();
@@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
const isOutgoing = computed(() => {
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const textToShow = computed(() => {
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
// Use TextContent as the default to fullHTML
const originalEmailHtml = computed(
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
);
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => {
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
});
const unquotedHTML = computed(() => {
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
const unquotedHTML = computed(() =>
EmailQuoteExtractor.extractQuotes(fullHTML.value)
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
});
const hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
@@ -75,7 +119,7 @@ const hasQuotedMessage = computed(() => {
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
<FormattedContent
v-if="isOutgoing && content"
class="text-n-slate-12"
:content="content"
:content="messageContent"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
@@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
/>
<Letter
v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
@@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
</button>
</div>
</section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2"
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } =
useMessageContext();
const hasTranslations = computed(() => {
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const renderOriginal = ref(false);
@@ -22,8 +22,7 @@ const renderContent = computed(() => {
}
if (hasTranslations.value) {
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
return translationContent.value;
}
return content.value;
@@ -37,12 +36,6 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
@@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="renderContent" :content="renderContent" />
<span class="-mt-3">
<span
v-if="hasTranslations"
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@click="handleSeeOriginal"
>
{{ $t(viewToggleKey) }}
</span>
</span>
<TranslationToggle
v-if="hasTranslations"
class="-mt-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-ruby-12',
pdf: 'text-n-slate-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -37,18 +36,6 @@ const toggleShortcutModalFn = show => {
}
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
@@ -116,32 +103,7 @@ const newReportRoutes = () => [
},
];
const oldReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
);
const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [
@@ -127,7 +127,6 @@ const settings = accountId => ({
meta: {
permissions: ['administrator'],
},
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
@@ -49,13 +49,6 @@ export default {
return !!this.menuItem.children;
},
isMenuItemVisible() {
if (this.menuItem.globalConfigFlag) {
// this checks for the `csmlEditorHost` flag in the global config
// if this is present, we toggle the CSML editor menu item
// TODO: This is very specific, and can be handled better, fix it
return !!this.globalConfig[this.menuItem.globalConfigFlag];
}
let isFeatureEnabled = true;
if (this.menuItem.featureFlag) {
isFeatureEnabled = this.isFeatureEnabledonAccount(
@@ -17,6 +17,9 @@ export default {
hasFbConfigured() {
return window.chatwootConfig?.fbAppId;
},
hasInstagramConfigured() {
return window.chatwootConfig?.instagramAppId;
},
isActive() {
const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) {
@@ -33,7 +36,9 @@ export default {
}
if (key === 'instagram') {
return this.enabledFeatures.channel_instagram;
return (
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
return [
@@ -202,7 +202,7 @@ export default {
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAInstagramChannel || this.isInstagramDM) {
if (this.isAnInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
@@ -36,6 +36,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -209,6 +210,22 @@ export default {
return contactLastSeenAt;
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -232,7 +249,7 @@ export default {
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAInstagramChannel) {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
@@ -247,7 +264,7 @@ export default {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAInstagramChannel
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
@@ -503,6 +520,12 @@ export default {
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
@@ -241,15 +241,27 @@ export default {
if (this.isAFacebookInbox) {
return MESSAGE_MAX_LENGTH.FACEBOOK;
}
if (this.isAWhatsAppChannel) {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
if (this.isAWhatsAppCloudChannel) {
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
}
if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isAnEmailChannel) {
return MESSAGE_MAX_LENGTH.EMAIL;
}
if (this.isATwilioSMSChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isAWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
}
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
@@ -262,7 +274,7 @@ export default {
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel ||
this.isAInstagramChannel
this.isAnInstagramChannel
);
},
replyButtonLabel() {
@@ -388,9 +400,14 @@ export default {
},
},
watch: {
currentChat(conversation) {
currentChat(conversation, oldConversation) {
const { can_reply: canReply } = conversation;
this.setCCAndToEmailsFromLastChat();
if (oldConversation && oldConversation.id !== conversation.id) {
// Only update email fields when switching to a completely different conversation (by ID)
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
}
if (this.isOnPrivateNote) {
return;
@@ -406,13 +423,12 @@ export default {
},
// When moving from one conversation to another, the store may not have the
// list of all the messages. A fetch is subsequently made to get the messages.
// However, this update does not trigger the `currentChat` watcher.
// We can add a deep watcher to it, but then, that would be too broad of a net to cast
// And would impact performance too. So we watch the messages directly.
// The watcher here is `deep` too, because the messages array is mutated and
// not replaced. So, a shallow watcher would not catch the change.
'currentChat.messages': {
handler() {
// This watcher handles two main cases:
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages).
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change.
lastEmail: {
handler(lastEmail) {
if (!lastEmail) return;
this.setCCAndToEmailsFromLastChat();
},
deep: true,
@@ -686,7 +702,11 @@ export default {
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(this.message);
} else {
const messagePayload = this.getMessagePayload(this.message);
@@ -703,7 +723,7 @@ export default {
}
},
sendMessageAsMultipleMessages(message) {
const messages = this.getMessagePayloadForWhatsapp(message);
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
this.sendMessage(messagePayload);
});
@@ -935,11 +955,11 @@ export default {
return payload;
},
getMessagePayloadForWhatsapp(message) {
getMultipleMessagesPayload(message) {
const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) {
let caption = message;
let caption = this.isAnInstagramChannel ? '' : message;
this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
@@ -954,9 +974,19 @@ export default {
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
multipleMessagePayload.push(attachmentPayload);
caption = '';
// For WhatsApp, only the first attachment gets a caption
if (!this.isAnInstagramChannel) caption = '';
});
} else {
}
const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments
if (
(this.isAnInstagramChannel && this.message) ||
(!this.isAnInstagramChannel && hasNoAttachments)
) {
let messagePayload = {
conversationId: this.currentChat.id,
message,
@@ -1136,7 +1166,7 @@ export default {
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="input"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
@@ -124,7 +124,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
class="absolute right-0 top-[32px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
@@ -0,0 +1,39 @@
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
});
});
@@ -121,7 +121,7 @@ export const useInbox = () => {
);
});
const isAInstagramChannel = computed(() => {
const isAnInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
@@ -141,6 +141,6 @@ export const useInbox = () => {
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
isAnInstagramChannel,
};
};
@@ -0,0 +1,22 @@
import { computed } from 'vue';
/**
* Composable to extract translation state/content from contentAttributes.
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
});
return { hasTranslations, translationContent };
}
@@ -1,6 +1,44 @@
export const buildPortalURL = portalSlug => {
const { hostURL, helpCenterURL } = window.chatwootConfig;
/**
* Formats a custom domain with https protocol if needed
* @param {string} customDomain - The custom domain to format
* @returns {string} Formatted domain with https protocol
*/
const formatCustomDomain = customDomain =>
customDomain.startsWith('https') ? customDomain : `https://${customDomain}`;
/**
* Gets the default base URL from configuration
* @returns {string} The default base URL
* @throws {Error} If no valid base URL is found
*/
const getDefaultBaseURL = () => {
const { hostURL, helpCenterURL } = window.chatwootConfig || {};
const baseURL = helpCenterURL || hostURL || '';
if (!baseURL) {
throw new Error('No valid base URL found in configuration');
}
return baseURL;
};
/**
* Gets the base URL from configuration or custom domain
* @param {string} [customDomain] - Optional custom domain for the portal
* @returns {string} The base URL for the portal
*/
const getPortalBaseURL = customDomain =>
customDomain ? formatCustomDomain(customDomain) : getDefaultBaseURL();
/**
* Builds a portal URL using the provided portal slug and optional custom domain
* @param {string} portalSlug - The slug identifier for the portal
* @param {string} [customDomain] - Optional custom domain for the portal
* @returns {string} The complete portal URL
* @throws {Error} If portalSlug is not provided or invalid
*/
export const buildPortalURL = (portalSlug, customDomain) => {
const baseURL = getPortalBaseURL(customDomain);
return `${baseURL}/hc/${portalSlug}`;
};
@@ -8,9 +46,10 @@ export const buildPortalArticleURL = (
portalSlug,
categorySlug,
locale,
articleSlug
articleSlug,
customDomain
) => {
const portalURL = buildPortalURL(portalSlug);
const portalURL = buildPortalURL(portalSlug, customDomain);
return `${portalURL}/articles/${articleSlug}`;
};
@@ -25,5 +25,47 @@ describe('PortalHelper', () => {
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
window.chatwootConfig = {};
});
it('returns the correct url with custom domain', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(
buildPortalArticleURL(
'handbook',
'culture',
'fr',
'article-slug',
'custom-domain.dev'
)
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
});
it('handles https in custom domain correctly', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(
buildPortalArticleURL(
'handbook',
'culture',
'fr',
'article-slug',
'https://custom-domain.dev'
)
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
});
it('uses hostURL when helpCenterURL is not available', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: '',
};
expect(
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
});
});
});
@@ -2,23 +2,13 @@
"AGENT_BOTS": {
"HEADER": "Bots",
"LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"CSML_BOT_EDITOR": {
"NAME": {
"LABEL": "Bot name",
"PLACEHOLDER": "Name your bot.",
"ERROR": "Bot name is required."
},
"DESCRIPTION": {
"LABEL": "Bot description",
"PLACEHOLDER": "What does this bot do?"
},
"BOT_CONFIG": {
"ERROR": "Please enter your CSML bot configuration above.",
"API_ERROR": "Your CSML configuration is invalid. Please fix it and try again."
},
"SUBMIT": "Validate and save"
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
"AVATAR": {
"SUCCESS_DELETE": "Bot avatar deleted successfully",
"ERROR_DELETE": "Error deleting bot avatar, please try again"
},
"BOT_CONFIGURATION": {
"TITLE": "Select an agent bot",
@@ -32,7 +22,7 @@
"SELECT_PLACEHOLDER": "Select bot"
},
"ADD": {
"TITLE": "Configure new bot",
"TITLE": "Add Bot",
"CANCEL_BUTTON_TEXT": "Cancel",
"API": {
"SUCCESS_MESSAGE": "Bot added successfully.",
@@ -40,16 +30,22 @@
}
},
"LIST": {
"404": "No bots found. You can create a bot by clicking the 'Configure new bot' button",
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
"LOADING": "Fetching bots...",
"TYPE": "Bot type"
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "Webhook URL"
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"TITLE": "Delete bot",
"SUBMIT": "Delete",
"CANCEL_BUTTON_TEXT": "Cancel",
"DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible.",
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure you want to delete {name}?",
"YES": "Yes, Delete",
"NO": "No, Keep"
},
"API": {
"SUCCESS_MESSAGE": "Bot deleted successfully.",
"ERROR_MESSAGE": "Could not delete bot. Please try again."
@@ -57,17 +53,44 @@
},
"EDIT": {
"BUTTON_TEXT": "Edit",
"LOADING": "Fetching bots...",
"TITLE": "Edit bot",
"CANCEL_BUTTON_TEXT": "Cancel",
"API": {
"SUCCESS_MESSAGE": "Bot updated successfully.",
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
},
"NAME": {
"LABEL": "Bot name",
"PLACEHOLDER": "Enter bot name",
"REQUIRED": "Bot name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "What does this bot do?"
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "https://example.com/webhook",
"REQUIRED": "Webhook URL is required"
},
"ERRORS": {
"NAME": "Bot name is required",
"URL": "Webhook URL is required",
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
},
"CANCEL": "Cancel",
"CREATE": "Create Bot",
"UPDATE": "Update Bot"
},
"WEBHOOK": {
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
},
"TYPES": {
"WEBHOOK": "Webhook bot",
"CSML": "CSML bot"
"WEBHOOK": "Webhook bot"
}
}
}
@@ -37,6 +37,7 @@
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -51,7 +51,9 @@
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
@@ -120,6 +120,7 @@ export default {
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('portals/index');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
@@ -1,6 +1,7 @@
<script>
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import allLocales from 'shared/constants/locales.js';
import SearchHeader from './Header.vue';
@@ -33,6 +34,15 @@ export default {
};
},
computed: {
...mapGetters({
portalBySlug: 'portals/portalBySlug',
}),
portal() {
return this.portalBySlug(this.selectedPortalSlug);
},
portalCustomDomain() {
return this.portal?.custom_domain;
},
articleViewerUrl() {
const article = this.activeArticle(this.activeId);
if (!article) return '';
@@ -47,6 +57,7 @@ export default {
return `${url}`;
},
searchResultsWithUrl() {
return this.searchResults.map(article => ({
...article,
@@ -65,7 +76,8 @@ export default {
this.selectedPortalSlug,
'',
'',
article.slug
article.slug,
this.portalCustomDomain
);
},
localeName(code) {
@@ -111,7 +123,6 @@ export default {
},
onInsert(id) {
const article = this.activeArticle(id || this.activeId);
this.$emit('insert', article);
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
this.onClose();
@@ -20,17 +20,23 @@ const articleById = useMapGetter('articles/articleById');
const article = computed(() => articleById.value(articleSlug));
const portalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => portalBySlug.value(portalSlug));
const isUpdating = ref(false);
const isSaved = ref(false);
const portalLink = computed(() => {
const articleLink = computed(() => {
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
const { slug: articleSlugValue } = article.value;
const portalCustomDomain = portal.value?.custom_domain;
return buildPortalArticleURL(
portalSlug,
categorySlug,
categoryLocale,
articleSlugValue
articleSlugValue,
portalCustomDomain
);
});
@@ -91,7 +97,7 @@ const fetchArticleDetails = () => {
};
const previewArticle = () => {
window.open(portalLink.value, '_blank');
window.open(articleLink.value, '_blank');
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
status: article.value?.status,
});
@@ -1,102 +1,191 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import AgentBotRow from './components/AgentBotRow.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const MODAL_TYPES = {
CREATE: 'create',
EDIT: 'edit',
};
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags');
const confirmDialog = ref(null);
const selectedBot = ref({});
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
const agentBotDeleteDialogRef = ref(null);
const onConfigureNewBot = () => {
router.push({
name: 'agent_bots_csml_new',
});
const tableHeaders = computed(() => {
return [
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
agentBotModalRef.value.dialogRef.open();
};
const openEditModal = bot => {
modalType.value = MODAL_TYPES.EDIT;
selectedBot.value = bot;
agentBotModalRef.value.dialogRef.open();
};
const openDeletePopup = bot => {
selectedBot.value = bot;
agentBotDeleteDialogRef.value.open();
};
const deleteAgentBot = async id => {
try {
await store.dispatch('agentBots/delete', id);
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
} finally {
loading.value[id] = false;
selectedBot.value = {};
}
};
const confirmDeletion = () => {
loading.value[selectedBot.value.id] = true;
deleteAgentBot(selectedBot.value.id);
agentBotDeleteDialogRef.value.close();
};
onMounted(() => {
store.dispatch('agentBots/get');
});
const onDeleteAgentBot = async bot => {
const ok = await confirmDialog.value.showConfirmation();
if (ok) {
try {
await store.dispatch('agentBots/delete', bot.id);
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
}
}
};
const onEditAgentBot = bot => {
router.push(
frontendURL(
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
)
);
};
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('AGENT_BOTS.LIST.LOADING')"
:loading-message="t('AGENT_BOTS.LIST.LOADING')"
:no-records-found="!agentBots.length"
:no-records-message="$t('AGENT_BOTS.LIST.404')"
:no-records-message="t('AGENT_BOTS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AGENT_BOTS.HEADER')"
:description="$t('AGENT_BOTS.DESCRIPTION')"
:link-text="$t('AGENT_BOTS.LEARN_MORE')"
:title="t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')"
feature-name="agent_bots"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_BOTS.ADD.TITLE')"
@click="onConfigureNewBot"
@click="openAddModal"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex-1 overflow-auto">
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<tbody class="divide-y divide-n-weak text-n-slate-11">
<AgentBotRow
v-for="(agentBot, index) in agentBots"
:key="agentBot.id"
:agent-bot="agentBot"
:index="index"
@delete="onDeleteAgentBot"
@edit="onEditAgentBot"
/>
</tbody>
</table>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('AGENT_BOTS.DELETE.TITLE')"
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
/>
</div>
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
<tr v-for="bot in agentBots" :key="bot.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
rounded-full
/>
<div>
<span class="block font-medium break-words">
{{ bot.name }}
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</span>
<span class="text-sm text-n-slate-11">
{{ bot.description }}
</span>
</div>
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<AgentBotModal
ref="agentBotModalRef"
:type="modalType"
:selected-bot="selectedBot"
/>
<Dialog
ref="agentBotDeleteDialogRef"
type="alert"
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
:description="
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
@confirm="confirmDeletion"
/>
</SettingsLayout>
</template>
@@ -1,9 +1,6 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import Bot from './Index.vue';
import CsmlEditBot from './csml/Edit.vue';
import CsmlNewBot from './csml/New.vue';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
@@ -26,36 +23,5 @@ export default {
},
],
},
{
path: frontendURL('accounts/:accountId/settings/agent-bots'),
component: SettingsContent,
props: () => {
return {
headerTitle: 'AGENT_BOTS.HEADER',
icon: 'bot',
showBackButton: true,
};
},
children: [
{
path: 'csml/new',
name: 'agent_bots_csml_new',
component: CsmlNewBot,
meta: {
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
permissions: ['administrator'],
},
},
{
path: 'csml/:botId',
name: 'agent_bots_csml_edit',
component: CsmlEditBot,
meta: {
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
permissions: ['administrator'],
},
},
],
},
],
};
@@ -0,0 +1,251 @@
<script setup>
import { ref, computed, reactive, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
selectedBot: {
type: Object,
default: () => ({}),
},
});
const MODAL_TYPES = {
CREATE: 'create',
EDIT: 'edit',
};
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const uiFlags = useMapGetter('agentBots/getUIFlags');
const formState = reactive({
botName: '',
botDescription: '',
botUrl: '',
botAvatar: null,
botAvatarUrl: '',
});
const v$ = useVuelidate(
{
botName: {
required: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
required
),
},
botUrl: {
required: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
required
),
url: helpers.withMessage(
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
url
),
},
},
formState
);
const isLoading = computed(() =>
props.type === MODAL_TYPES.CREATE
? uiFlags.value.isCreating
: uiFlags.value.isUpdating
);
const dialogTitle = computed(() =>
props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE')
);
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.FORM.CREATE')
: t('AGENT_BOTS.FORM.UPDATE')
);
const botNameError = computed(() =>
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
);
const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
const resetForm = () => {
Object.assign(formState, {
botName: '',
botDescription: '',
botUrl: '',
botAvatar: null,
botAvatarUrl: '',
});
v$.value.$reset();
};
const handleImageUpload = ({ file, url: avatarUrl }) => {
formState.botAvatar = file;
formState.botAvatarUrl = avatarUrl;
};
const handleAvatarDelete = async () => {
if (props.selectedBot?.id) {
try {
await store.dispatch(
'agentBots/deleteAgentBotAvatar',
props.selectedBot.id
);
formState.botAvatar = null;
formState.botAvatarUrl = '';
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
} catch (error) {
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
}
} else {
formState.botAvatar = null;
formState.botAvatarUrl = '';
}
};
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const botData = {
name: formState.botName,
description: formState.botDescription,
outgoing_url: formState.botUrl,
bot_type: 'webhook',
avatar: formState.botAvatar,
};
const isCreate = props.type === MODAL_TYPES.CREATE;
try {
const actionPayload = isCreate
? botData
: { id: props.selectedBot.id, data: botData };
await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
const alertKey = isCreate
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
dialogRef.value.close();
resetForm();
} catch (error) {
const errorKey = isCreate
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
useAlert(errorKey);
}
};
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } =
props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
} else {
resetForm();
}
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:show-cancel-button="false"
:show-confirm-button="false"
@close="v$.$reset()"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()"
/>
<NextButton
type="submit"
data-testid="label-submit"
:label="confirmButtonLabel"
:is-loading="isLoading"
:disabled="v$.$invalid"
/>
</div>
</form>
</Dialog>
</template>
@@ -1,59 +0,0 @@
<script setup>
import { computed } from 'vue';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import AgentBotType from './AgentBotType.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
agentBot: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
});
const emit = defineEmits(['edit', 'delete']);
const isACSMLTypeBot = computed(() => {
const { bot_type: botType } = props.agentBot;
return botType === 'csml';
});
</script>
<template>
<tr class="space-x-2">
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
<div class="flex items-center break-words font-medium">
{{ agentBot.name }}
(<AgentBotType :bot-type="agentBot.bot_type" />)
</div>
<div class="text-sm">
<ShowMore :text="agentBot.description || ''" :limit="120" />
</div>
</td>
<td class="align-middle">
<div class="flex justify-end gap-1 h-full items-center">
<Button
v-if="isACSMLTypeBot"
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', agentBot)"
/>
<Button
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="emit('delete', agentBot, index)"
/>
</div>
</td>
</tr>
</template>
@@ -1,36 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
botType: {
type: String,
default: 'webhook',
},
});
const { t } = useI18n();
const botTypeConfig = computed(() => ({
csml: {
label: t('AGENT_BOTS.TYPES.CSML'),
thumbnail: '/dashboard/images/agent-bots/csml.png',
},
webhook: {
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
},
}));
</script>
<template>
<span class="inline-flex items-center gap-1">
<img
v-tooltip="botTypeConfig[botType].label"
class="h-3 w-auto"
:src="botTypeConfig[botType].thumbnail"
:alt="botTypeConfig[botType].label"
/>
<span>{{ botTypeConfig[botType].label }}</span>
</span>
</template>
@@ -1,99 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { CsmlMonacoEditor, NextButton },
props: {
agentBot: {
type: Object,
default: () => {},
},
},
emits: ['submit'],
setup() {
return { v$: useVuelidate() };
},
validations: {
bot: {
name: { required },
csmlContent: { required },
},
},
data() {
return {
bot: {
name: this.agentBot.name || '',
description: this.agentBot.description || '',
csmlContent: this.agentBot.bot_config.csml_content || '',
},
};
},
methods: {
onSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.$emit('submit', {
id: this.agentBot.id || '',
...this.bot,
});
},
},
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-row">
<div class="w-[68%]">
<div class="h-[calc(100vh-56px)] relative">
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
<div
v-if="v$.bot.csmlContent.$error"
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
>
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
</div>
</div>
</div>
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
<form
class="flex flex-col justify-between h-full"
@submit.prevent="onSubmit"
>
<div>
<label :class="{ error: v$.bot.name.$error }">
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
<input
v-model="bot.name"
type="text"
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
/>
<span v-if="v$.bot.name.$error" class="message">
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
</span>
</label>
<label>
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
<textarea
v-model="bot.description"
rows="4"
:placeholder="
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
"
/>
</label>
<NextButton
type="submit"
:label="$t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT')"
/>
</div>
</form>
</div>
</div>
</div>
</template>
@@ -1,71 +0,0 @@
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
export default {
components: { LoadingState },
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
data() {
return {
iframeLoading: true,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
window.onmessage = e => {
if (
typeof e.data !== 'string' ||
!e.data.startsWith('chatwoot-csml-editor:update')
) {
return;
}
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
this.$emit('update:modelValue', csmlContent);
};
},
methods: {
onEditorLoad() {
const frameElement = document.getElementById(`csml-editor--frame`);
const eventData = {
event: 'editorContext',
data: this.modelValue || '',
};
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
},
},
};
</script>
<template>
<div class="csml-editor--container">
<LoadingState
v-if="iframeLoading"
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
class="dashboard-app_loading-container"
/>
<iframe
id="csml-editor--frame"
:src="globalConfig.csmlEditorHost"
@load="onEditorLoad"
/>
</div>
</template>
<style scoped>
#csml-editor--frame {
border: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,40 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import Spinner from 'shared/components/Spinner.vue';
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
export default {
components: { Spinner, CsmlBotEditor },
computed: {
agentBot() {
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
},
},
mounted() {
this.$store.dispatch('agentBots/show', this.$route.params.botId);
},
methods: {
async updateBot(bot) {
try {
await this.$store.dispatch('agentBots/update', {
id: bot.id,
name: bot.name,
description: bot.description,
bot_type: 'csml',
bot_config: { csml_content: bot.csmlContent },
});
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
}
},
},
};
</script>
<template>
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
<Spinner />
</div>
</template>
@@ -1,41 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { frontendURL } from '../../../../../helper/URLHelper';
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
export default {
components: { CsmlBotEditor },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
methods: {
async saveBot(bot) {
try {
const agentBot = await this.$store.dispatch('agentBots/create', {
name: bot.name,
description: bot.description,
bot_type: 'csml',
bot_config: { csml_content: bot.csmlContent },
});
if (agentBot) {
this.$router.replace(
frontendURL(
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
)
);
}
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
</template>
@@ -35,8 +35,7 @@ export default {
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
// TODO: Add Instagram to the channel list after the feature is ready to use.
// { key: 'instagram', name: 'Instagram' },
{ key: 'instagram', name: 'Instagram' },
];
},
...mapGetters({
@@ -1,11 +1,13 @@
<script>
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
EmptyState,
NextButton,
DuplicateInboxBanner,
},
computed: {
currentInbox() {
@@ -16,6 +18,20 @@ export default {
isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms';
},
// Check if a facebook inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.currentInbox.instagram_id;
const facebookInbox =
this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
instagramId
);
return (
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
facebookInbox
);
},
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
@@ -72,8 +88,12 @@ export default {
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
/>
<EmptyState
:title="$t('INBOX_MGMT.FINISH.TITLE')"
:message="message"
@@ -8,6 +8,7 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
@@ -20,6 +21,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
@@ -38,6 +40,7 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
},
mixins: [inboxMixin],
setup() {
@@ -138,11 +141,7 @@ export default {
}
if (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.AGENT_BOTS
) &&
!(this.isAnEmailChannel || this.isATwitterInbox)
this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.AGENT_BOTS)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@@ -205,7 +204,17 @@ export default {
return false;
},
instagramUnauthorized() {
return this.isAInstagramChannel && this.inbox.reauthorization_required;
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
// Check if a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return this.inbox.channel_type === INBOX_TYPES.FB && instagramInbox;
},
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
@@ -393,6 +402,11 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
@@ -0,0 +1,20 @@
<script setup>
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
content: {
type: String,
default: '',
},
});
</script>
<template>
<Banner color="amber">
<div class="flex items-center gap-2">
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4" />
<span>{{ content }}</span>
</div>
</Banner>
</template>
@@ -12,6 +12,7 @@ export const state = {
isCreating: false,
isDeleting: false,
isUpdating: false,
isUpdatingAvatar: false,
isFetchingAgentBot: false,
isSettingAgentBot: false,
isDisconnecting: false,
@@ -48,10 +49,23 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit }, agentBotObj) => {
create: async ({ commit }, botData) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
try {
const response = await AgentBotsAPI.create(agentBotObj);
// Create FormData for file upload
const formData = new FormData();
formData.append('name', botData.name);
formData.append('description', botData.description);
formData.append('bot_type', botData.bot_type || 'webhook');
formData.append('outgoing_url', botData.outgoing_url);
// Add avatar file if available
if (botData.avatar) {
formData.append('avatar', botData.avatar);
}
const response = await AgentBotsAPI.create(formData);
commit(types.ADD_AGENT_BOT, response.data);
return response.data;
} catch (error) {
@@ -61,10 +75,22 @@ export const actions = {
}
return null;
},
update: async ({ commit }, { id, ...agentBotObj }) => {
update: async ({ commit }, { id, data }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
try {
const response = await AgentBotsAPI.update(id, agentBotObj);
// Create FormData for file upload
const formData = new FormData();
formData.append('name', data.name);
formData.append('description', data.description);
formData.append('bot_type', data.bot_type || 'webhook');
formData.append('outgoing_url', data.outgoing_url);
if (data.avatar) {
formData.append('avatar', data.avatar);
}
const response = await AgentBotsAPI.update(id, formData);
commit(types.EDIT_AGENT_BOT, response.data);
} catch (error) {
throwErrorMessage(error);
@@ -72,6 +98,7 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
try {
@@ -83,6 +110,20 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
}
},
deleteAgentBotAvatar: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
try {
await AgentBotsAPI.deleteAgentBotAvatar(id);
// Update the thumbnail to empty string after deletion
commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
}
},
show: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
try {
@@ -150,6 +191,12 @@ export const mutations = {
[inboxId]: agentBotId,
};
},
[types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
const botIndex = $state.records.findIndex(bot => bot.id === id);
if (botIndex !== -1) {
$state.records[botIndex].thumbnail = thumbnail || '';
}
},
};
export default {
@@ -122,6 +122,20 @@ export const getters = {
item => item.channel_type !== INBOX_TYPES.EMAIL
);
},
getFacebookInboxByInstagramId: $state => instagramId => {
return $state.records.find(
item =>
item.instagram_id === instagramId &&
item.channel_type === INBOX_TYPES.FB
);
},
getInstagramInboxByInstagramId: $state => instagramId => {
return $state.records.find(
item =>
item.instagram_id === instagramId &&
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
};
const sendAnalyticsEvent = channelType => {
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../agentBots';
import types from '../../../mutation-types';
import { agentBotRecords } from './fixtures';
import { agentBotRecords, agentBotData } from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -30,16 +30,22 @@ describe('#actions', () => {
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
await actions.create({ commit }, agentBotRecords[0]);
await actions.create({ commit }, agentBotData);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.ADD_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
]);
expect(axios.post.mock.calls.length).toBe(1);
const formDataArg = axios.post.mock.calls[0][1];
expect(formDataArg instanceof FormData).toBe(true);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
@@ -50,17 +56,29 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
await actions.update({ commit }, agentBotRecords[0]);
await actions.update(
{ commit },
{
id: agentBotRecords[0].id,
data: agentBotData,
}
);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
]);
expect(axios.patch.mock.calls.length).toBe(1);
const formDataArg = axios.patch.mock.calls[0][1];
expect(formDataArg instanceof FormData).toBe(true);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update({ commit }, agentBotRecords[0])
actions.update({ commit }, { id: 1, data: {} })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
@@ -68,7 +86,6 @@ describe('#actions', () => {
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
@@ -1,15 +1,35 @@
export const agentBotRecords = [
{
account_id: 1,
id: 11,
name: 'Agent Bot 11',
description: 'Agent Bot Description',
type: 'csml',
bot_type: 'webhook',
thumbnail: 'https://example.com/thumbnail.jpg',
bot_config: {},
outgoing_url: 'https://example.com/outgoing',
access_token: 'hN8QwG769RqBXmme',
system_bot: false,
},
{
account_id: 1,
id: 12,
name: 'Agent Bot 12',
description: 'Agent Bot Description 12',
type: 'csml',
bot_type: 'webhook',
thumbnail: 'https://example.com/thumbnail.jpg',
bot_config: {},
outgoing_url: 'https://example.com/outgoing',
access_token: 'hN8QwG769RqBXmme',
system_bot: false,
},
];
export const agentBotData = {
name: 'Test Bot',
description: 'Test Description',
outgoing_url: 'https://test.com',
bot_type: 'webhook',
avatar: new File([''], 'filename'),
};
@@ -51,4 +51,16 @@ describe('#mutations', () => {
expect(state.agentBotInbox).toEqual({ 3: 2 });
});
});
describe('#UPDATE_AGENT_BOT_AVATAR', () => {
it('update agent bot avatar', () => {
const state = { records: [agentBotRecords[0]] };
mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
id: 11,
thumbnail: 'https://example.com/thumbnail.jpg',
});
expect(state.records[0].thumbnail).toEqual(
'https://example.com/thumbnail.jpg'
);
});
});
});
@@ -9,6 +9,7 @@ export default [
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
},
{
id: 2,
@@ -62,4 +63,12 @@ export default [
channel_type: 'Channel::Sms',
provider: 'default',
},
{
id: 7,
channel_id: 7,
name: 'Test Instagram 1',
channel_type: 'Channel::Instagram',
instagram_id: 123456789,
provider: 'default',
},
];
@@ -26,7 +26,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
});
it('getInbox', () => {
@@ -43,6 +43,7 @@ describe('#getters', () => {
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
});
});
@@ -64,4 +65,32 @@ describe('#getters', () => {
isDeleting: false,
});
});
it('getFacebookInboxByInstagramId', () => {
const state = { records: inboxList };
expect(getters.getFacebookInboxByInstagramId(state)(123456789)).toEqual({
id: 1,
channel_id: 1,
name: 'Test FacebookPage 1',
channel_type: 'Channel::FacebookPage',
avatar_url: 'random_image.png',
page_id: '12345',
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
});
});
it('getInstagramInboxByInstagramId', () => {
const state = { records: inboxList };
expect(getters.getInstagramInboxByInstagramId(state)(123456789)).toEqual({
id: 7,
channel_id: 7,
name: 'Test Instagram 1',
channel_type: 'Channel::Instagram',
instagram_id: 123456789,
provider: 'default',
});
});
});
@@ -301,6 +301,7 @@ export default {
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
// MACROS
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
-1
View File
@@ -135,7 +135,6 @@ export const SDK_CSS = `
.woot-widget-bubble svg {
all: revert;
height: 24px;
margin: 20px;
width: 24px;
}
@@ -44,6 +44,7 @@ export const ALLOWED_FILE_TYPES =
'video/*,' +
'.3gpp,' +
'text/csv, text/plain, application/json, application/pdf, text/rtf,' +
'application/xml, text/xml,' +
'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' +
'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' +
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
@@ -4,8 +4,20 @@ export const isASubmittedFormMessage = (message = {}) =>
export const MESSAGE_MAX_LENGTH = {
GENERAL: 10000,
FACEBOOK: 1000,
// https://developers.facebook.com/docs/messenger-platform/reference/send-api#request
FACEBOOK: 2000,
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
INSTAGRAM: 1000,
// https://www.twilio.com/docs/glossary/what-sms-character-limit
TWILIO_SMS: 320,
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
TWILIO_WHATSAPP: 1600,
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-object
WHATSAPP_CLOUD: 4096,
// https://support.bandwidth.com/hc/en-us/articles/360010235373-What-are-Bandwidth-s-SMS-character-limits-and-concatenation-practices
BANDWIDTH_SMS: 160,
// https://core.telegram.org/bots/api#sendmessage
TELEGRAM: 4096,
LINE: 2000,
EMAIL: 25000,
};
+1 -1
View File
@@ -121,7 +121,7 @@ export default {
this.isATwilioWhatsAppChannel
);
},
isAInstagramChannel() {
isAnInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
},
@@ -5,7 +5,6 @@ const {
AZURE_APP_ID: azureAppId,
BRAND_NAME: brandName,
CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
CSML_EDITOR_HOST: csmlEditorHost,
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
DISPLAY_MANIFEST: displayManifest,
@@ -29,7 +28,6 @@ const state = {
azureAppId,
brandName,
chatwootInboxToken,
csmlEditorHost,
deploymentEnv,
createNewAccountFromDashboard,
directUploadsEnabled: directUploadsEnabled === 'true',
-10
View File
@@ -1,10 +0,0 @@
class AgentBots::CsmlJob < ApplicationJob
queue_as :high
def perform(event, agent_bot, message)
event_data = { message: message }
Integrations::Csml::ProcessorService.new(
event_name: event, agent_bot: agent_bot, event_data: event_data
).perform
end
end
+4 -12
View File
@@ -59,14 +59,10 @@ class AgentBotListener < BaseListener
true
end
def process_message_event(method_name, agent_bot, message, event)
case agent_bot.bot_type
when 'webhook'
payload = message.webhook_data.merge(event: method_name)
process_webhook_bot_event(agent_bot, payload)
when 'csml'
process_csml_bot_event(event.name, agent_bot, message)
end
def process_message_event(method_name, agent_bot, message, _event)
# Only webhook bots are supported
payload = message.webhook_data.merge(event: method_name)
process_webhook_bot_event(agent_bot, payload)
end
def process_webhook_bot_event(agent_bot, payload)
@@ -74,8 +70,4 @@ class AgentBotListener < BaseListener
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
end
def process_csml_bot_event(event, agent_bot, message)
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
end
end
+4
View File
@@ -1,4 +1,8 @@
class ConversationReplyMailer < ApplicationMailer
# We needs to expose large attachments to the view as links
# Small attachments are linked as mail attachments directly
attr_reader :large_attachments
include ConversationReplyMailerHelper
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
layout :choose_layout
@@ -17,11 +17,44 @@ module ConversationReplyMailerHelper
google_smtp_settings
set_delivery_method
Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
# Email type detection logic:
# - email_reply: Sets @message with a single message
# - Other actions: Set @messages with a collection of messages
#
# So this check implicitly determines we're handling an email_reply
# and not one of the other email types (summary, transcript, etc.)
process_attachments_as_files_for_email_reply if @message&.attachments.present?
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def google_smtp_settings
@@ -52,6 +85,8 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2'
}
end
+3 -6
View File
@@ -25,9 +25,8 @@ class AgentBot < ApplicationRecord
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
belongs_to :account, optional: true
enum bot_type: { webhook: 0, csml: 1 }
enum bot_type: { webhook: 0 }
validate :validate_agent_bot_config
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def available_name
@@ -51,9 +50,7 @@ class AgentBot < ApplicationRecord
}
end
private
def validate_agent_bot_config
errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
def system_bot?
account.nil?
end
end
@@ -1,38 +0,0 @@
class AgentBots::ValidateBotService
pattr_initialize [:agent_bot]
def perform
return true unless agent_bot.bot_type == 'csml'
validate_csml_bot
end
private
def csml_client
@csml_client ||= CsmlEngine.new
end
def csml_bot_payload
{
id: agent_bot[:name],
name: agent_bot[:name],
default_flow: 'Default',
flows: [
{
id: SecureRandom.uuid,
name: 'Default',
content: agent_bot.bot_config['csml_content'],
commands: []
}
]
}
end
def validate_csml_bot
response = csml_client.validate(csml_bot_payload)
response.blank? || response['valid']
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: agent_bot&.account).capture_exception
false
end
end
@@ -1,8 +1,10 @@
json.id resource.id
json.name resource.name
json.description resource.description
json.outgoing_url resource.outgoing_url
json.thumbnail resource.avatar_url
json.outgoing_url resource.outgoing_url unless resource.system_bot?
json.bot_type resource.bot_type
json.bot_config resource.bot_config
json.account_id resource.account_id
json.access_token resource.access_token if resource.access_token.present?
json.system_bot resource.system_bot?
@@ -56,6 +56,7 @@ end
## Instagram Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
+1
View File
@@ -35,6 +35,7 @@
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>',
fbAppId: '<%= @global_config['FB_APP_ID'] %>',
instagramAppId: '<%= @global_config['INSTAGRAM_APP_ID'] %>',
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
@@ -32,9 +32,10 @@
<% if message.content %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments %>
<% if message.attachments.present? %>
<p>Attachments:</p>
<% message.attachments.each do |attachment| %>
Attachment [<a href="<%= attachment.file_url %>" _target="blank">Click here to view</a>]
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<% end %>
<p style="font-size: 90%; font-size: 90%;color: #899096;margin-top: -8px; margin-bottom: 0px;">
@@ -1,8 +1,9 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %>
<% if @message.attachments %>
<% @message.attachments.each do |attachment| %>
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<% end %>
@@ -20,9 +20,10 @@
<% if message.content.present? %>
<hr style="border: 0; border-bottom: 1px solid #AEC3D5;"/>
<% end %>
This message contains <%= message.attachments.count > 1 ? 'attachments' : 'an attachment' %>.
<% message.attachments.each do |attachment| %>
<br />- View the attachment <a href="<%= attachment.file_url %>" _target="blank">here</a>.
Attachments:
<% message.attachments.each_with_index do |attachment, index| %>
<% if index > 0 %><br /><% end %>
<a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a>
<% end %>
</p>
<% end %>
@@ -3,9 +3,10 @@
<% if message.content %>
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
<% end %>
<% if message.attachments %>
<% if message.attachments.present? %>
<p>Attachments:</p>
<% message.attachments.each do |attachment| %>
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<% end %>
</p>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.0.4'
version: '4.1.0'
development:
<<: *shared
+5 -4
View File
@@ -37,7 +37,7 @@
help_url: https://chwt.app/hc/help-center
- name: agent_bots
display_name: Agent Bots
enabled: false
enabled: true
help_url: https://chwt.app/hc/agent-bots
- name: macros
display_name: Macros
@@ -149,7 +149,8 @@
enabled: false
- name: report_v4
display_name: Report V4
enabled: false
enabled: true
deprecated: true
- name: contact_chatwoot_support_team
display_name: Contact Chatwoot Support Team
enabled: true
@@ -161,7 +162,7 @@
- name: search_with_gin
display_name: Search messages with GIN
enabled: false
chatwoot_internal: true
- name: channel_instagram
display_name: Instagram Channel
enabled: false
chatwoot_internal: true
enabled: true
@@ -0,0 +1,13 @@
class ConvertCsmlBotsToWebhookBots < ActiveRecord::Migration[7.0]
def up
# Find all CSML bots (bot_type = 1) and convert them to webhook (bot_type = 0)
AgentBot.where(bot_type: 1).find_each do |bot|
bot.update(bot_type: 0, bot_config: {})
end
end
def down
# This migration is not reversible - we've removed CSML support
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,24 @@
class FlipChatwootV4DefaultFeatureFlagInstallationConfig < ActiveRecord::Migration[7.0]
def up
# Update the default feature flag config to enable chatwoot_v4
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
if config && config.value.present?
features = config.value.map do |f|
if f['name'] == 'chatwoot_v4'
f.merge('enabled' => true)
else
f
end
end
config.value = features
config.save!
end
# Enable chatwoot_v4 for all accounts in batches of 100
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each { |account| account.enable_features!('chatwoot_v4') }
end
GlobalConfig.clear_cache
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.0].define(version: 2025_04_02_233933) do
ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
+1 -1
View File
@@ -1,5 +1,5 @@
The Chatwoot Enterprise license (the “Enterprise License”)
Copyright (c) 2017-2021 Chatwoot Inc
Copyright (c) 2017-2025 Chatwoot Inc
With regard to the Chatwoot Software:
-52
View File
@@ -1,52 +0,0 @@
class CsmlEngine
API_KEY_HEADER = 'X-Api-Key'.freeze
def initialize
@host_url = GlobalConfigService.load('CSML_BOT_HOST', '')
@api_key = GlobalConfigService.load('CSML_BOT_API_KEY', '')
raise ArgumentError, 'Missing Credentials' if @host_url.blank? || @api_key.blank?
end
def status
response = HTTParty.get("#{@host_url}/status")
process_response(response)
end
def run(bot, params)
payload = {
bot: bot,
event: {
request_id: SecureRandom.uuid,
client: params[:client],
payload: params[:payload],
metadata: params[:metadata],
ttl_duration: 4000
}
}
response = post('run', payload)
process_response(response)
end
def validate(bot)
response = post('validate', bot)
process_response(response)
end
private
def process_response(response)
return response.parsed_response if response.success?
{ error: response.parsed_response, status: response.code }
end
def post(path, payload)
HTTParty.post(
"#{@host_url}/#{path}", {
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
body: payload.to_json
}
)
end
end
@@ -1,5 +1,4 @@
class Integrations::BotProcessorService
# TODO: In CSML processor service, the argument is agent bot, update initializers accordingly.
pattr_initialize [:event_name!, :hook!, :event_data!]
def perform
-142
View File
@@ -1,142 +0,0 @@
class Integrations::Csml::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :event_data!, :agent_bot!]
private
def csml_client
@csml_client ||= CsmlEngine.new
end
def get_response(session_id, content)
csml_client.run(
bot_payload,
{
client: client_params(session_id),
payload: message_payload(content),
metadata: metadata_params
}
)
end
def client_params(session_id)
{
bot_id: "chatwoot-bot-#{conversation.inbox.id}",
channel_id: "chatwoot-bot-inbox-#{conversation.inbox.id}",
user_id: session_id
}
end
def message_payload(content)
{
content_type: 'text',
content: { text: content }
}
end
def metadata_params
{
conversation: conversation,
contact: conversation.contact
}
end
def bot_payload
{
id: "chatwoot-csml-bot-#{agent_bot.id}",
name: "chatwoot-csml-bot-#{agent_bot.id}",
default_flow: 'chatwoot_bot_flow',
flows: [
{
id: "chatwoot-csml-bot-flow-#{agent_bot.id}-inbox-#{conversation.inbox.id}",
name: 'chatwoot_bot_flow',
content: agent_bot.bot_config['csml_content'],
commands: []
}
]
}
end
def process_response(message, response)
csml_messages = response['messages']
has_conversation_ended = response['conversation_end']
process_action(message, 'handoff') if has_conversation_ended.present?
return if csml_messages.blank?
# We do not support wait, typing now.
csml_messages.each do |csml_message|
create_messages(csml_message, conversation)
end
end
def create_messages(message, conversation)
message_payload = message['payload']
case message_payload['content_type']
when 'text'
process_text_messages(message_payload, conversation)
when 'question'
process_question_messages(message_payload, conversation)
when 'image'
process_image_messages(message_payload, conversation)
end
end
def process_text_messages(message_payload, conversation)
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: message_payload['content']['text'],
sender: agent_bot
}
)
end
def process_question_messages(message_payload, conversation)
buttons = message_payload['content']['buttons'].map do |button|
{ title: button['content']['title'], value: button['content']['payload'] }
end
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: message_payload['content']['title'],
content_type: 'input_select',
content_attributes: { items: buttons },
sender: agent_bot
}
)
end
def prepare_attachment(message_payload, message, account_id)
attachment_params = { file_type: :image, account_id: account_id }
attachment_url = message_payload['content']['url']
attachment = message.attachments.new(attachment_params)
attachment_file = Down.download(attachment_url)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def process_image_messages(message_payload, conversation)
message = conversation.messages.new(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: '',
content_type: 'text',
sender: agent_bot
}
)
prepare_attachment(message_payload, message, conversation.account_id)
message.save!
end
end
@@ -1,15 +1,19 @@
require 'google/cloud/translate/v3'
class Integrations::GoogleTranslate::ProcessorService
pattr_initialize [:message!, :target_language!]
def perform
return if message.content.blank?
return if hook.blank?
content = translation_content
return if content.blank?
response = client.translate_text(
contents: [message.content],
contents: [content],
target_language_code: target_language,
parent: "projects/#{hook.settings['project_id']}"
parent: "projects/#{hook.settings['project_id']}",
mime_type: mime_type
)
return if response.translations.first.blank?
@@ -19,6 +23,43 @@ class Integrations::GoogleTranslate::ProcessorService
private
def email_channel?
message&.inbox&.email?
end
def email_content
@email_content ||= {
html: message.content_attributes.dig('email', 'html_content', 'full'),
text: message.content_attributes.dig('email', 'text_content', 'full'),
content_type: message.content_attributes.dig('email', 'content_type')
}
end
def html_content_available?
email_content[:html].present?
end
def plain_text_content_available?
email_content[:content_type]&.include?('text/plain') &&
email_content[:text].present?
end
def translation_content
return message.content unless email_channel?
return email_content[:html] if html_content_available?
return email_content[:text] if plain_text_content_available?
message.content
end
def mime_type
if email_channel? && html_content_available?
'text/html'
else
'text/plain'
end
end
def hook
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
end
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.0.4",
"version": "4.1.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -138,7 +138,7 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"vite": "^5.4.17",
"vite": "^5.4.18",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
@@ -154,7 +154,7 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.17",
"vite": "5.4.18",
"vitest": "3.0.5"
}
},
+124 -124
View File
@@ -6,7 +6,7 @@ settings:
overrides:
vite-node: 2.0.1
vite: 5.4.17
vite: 5.4.18
vitest: 3.0.5
importers:
@@ -72,7 +72,7 @@ importers:
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
version: 5.1.4(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@vue/compiler-sfc':
specifier: ^3.5.8
version: 3.5.8
@@ -238,7 +238,7 @@ importers:
version: 1.8.1(tailwindcss@3.4.13)
'@histoire/plugin-vue':
specifier: 0.17.15
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -301,7 +301,7 @@ importers:
version: 6.0.0
histoire:
specifier: 0.17.15
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
husky:
specifier: ^7.0.0
version: 7.0.4
@@ -330,11 +330,11 @@ importers:
specifier: ^3.4.13
version: 3.4.13
vite:
specifier: 5.4.17
version: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
specifier: 5.4.18
version: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
version: 5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
version: 5.0.0(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
@@ -860,7 +860,7 @@ packages:
'@histoire/shared@0.17.17':
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
peerDependencies:
vite: 5.4.17
vite: 5.4.18
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
@@ -1039,103 +1039,103 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
'@rollup/rollup-android-arm-eabi@4.39.0':
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
'@rollup/rollup-android-arm-eabi@4.40.0':
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.39.0':
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
'@rollup/rollup-android-arm64@4.40.0':
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.39.0':
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
'@rollup/rollup-darwin-arm64@4.40.0':
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.39.0':
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
'@rollup/rollup-darwin-x64@4.40.0':
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.39.0':
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
'@rollup/rollup-freebsd-arm64@4.40.0':
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.39.0':
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
'@rollup/rollup-freebsd-x64@4.40.0':
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.39.0':
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
'@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.39.0':
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
'@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.39.0':
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
'@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.39.0':
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
'@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
'@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.39.0':
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
'@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.39.0':
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
'@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.39.0':
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
'@rollup/rollup-win32-ia32-msvc@4.40.0':
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.39.0':
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
'@rollup/rollup-win32-x64-msvc@4.40.0':
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
cpu: [x64]
os: [win32]
@@ -1800,7 +1800,7 @@ packages:
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: 5.4.17
vite: 5.4.18
vue: ^3.2.25
'@vitest/coverage-v8@3.0.5':
@@ -1819,7 +1819,7 @@ packages:
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
msw: ^2.4.9
vite: 5.4.17
vite: 5.4.18
peerDependenciesMeta:
msw:
optional: true
@@ -3100,7 +3100,7 @@ packages:
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
hasBin: true
peerDependencies:
vite: 5.4.17
vite: 5.4.18
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -4344,8 +4344,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@4.39.0:
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
rollup@4.40.0:
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4863,10 +4863,10 @@ packages:
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
peerDependencies:
vite: 5.4.17
vite: 5.4.18
vite@5.4.17:
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
vite@5.4.18:
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5672,10 +5672,10 @@ snapshots:
highlight.js: 11.10.0
vue: 3.5.12(typescript@5.6.2)
'@histoire/app@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/app@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
flexsearch: 0.7.21
@@ -5683,7 +5683,7 @@ snapshots:
transitivePeerDependencies:
- vite
'@histoire/controls@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/controls@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@codemirror/commands': 6.7.0
'@codemirror/lang-json': 6.0.1
@@ -5692,26 +5692,26 @@ snapshots:
'@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.34.1
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
transitivePeerDependencies:
- vite
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
change-case: 4.1.2
globby: 13.2.2
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
launch-editor: 2.9.1
pathe: 1.1.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- vite
'@histoire/shared@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/shared@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/vendors': 0.17.17
'@types/fs-extra': 9.0.13
@@ -5719,7 +5719,7 @@ snapshots:
chokidar: 3.6.0
pathe: 1.1.2
picocolors: 1.1.0
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@histoire/vendors@0.17.17': {}
@@ -5944,64 +5944,64 @@ snapshots:
'@rails/ujs@7.1.400': {}
'@rollup/rollup-android-arm-eabi@4.39.0':
'@rollup/rollup-android-arm-eabi@4.40.0':
optional: true
'@rollup/rollup-android-arm64@4.39.0':
'@rollup/rollup-android-arm64@4.40.0':
optional: true
'@rollup/rollup-darwin-arm64@4.39.0':
'@rollup/rollup-darwin-arm64@4.40.0':
optional: true
'@rollup/rollup-darwin-x64@4.39.0':
'@rollup/rollup-darwin-x64@4.40.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.39.0':
'@rollup/rollup-freebsd-arm64@4.40.0':
optional: true
'@rollup/rollup-freebsd-x64@4.39.0':
'@rollup/rollup-freebsd-x64@4.40.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.39.0':
'@rollup/rollup-linux-arm64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.39.0':
'@rollup/rollup-linux-arm64-musl@4.40.0':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.39.0':
'@rollup/rollup-linux-riscv64-musl@4.40.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.39.0':
'@rollup/rollup-linux-s390x-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.39.0':
'@rollup/rollup-linux-x64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.39.0':
'@rollup/rollup-linux-x64-musl@4.40.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.39.0':
'@rollup/rollup-win32-arm64-msvc@4.40.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.39.0':
'@rollup/rollup-win32-ia32-msvc@4.40.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.39.0':
'@rollup/rollup-win32-x64-msvc@4.40.0':
optional: true
'@rtsao/scc@1.1.0': {}
@@ -6802,9 +6802,9 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
'@vitejs/plugin-vue@5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
'@vitejs/plugin-vue@5.1.4(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vue: 3.5.12(typescript@5.6.2)
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
@@ -6832,13 +6832,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
'@vitest/mocker@3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@vitest/mocker@3.0.5(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -8393,12 +8393,12 @@ snapshots:
highlight.js@11.10.0: {}
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
'@akryum/tinypool': 0.3.1
'@histoire/app': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/app': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
'@types/markdown-it': 12.2.3
@@ -8425,7 +8425,7 @@ snapshots:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.4
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
@@ -9766,30 +9766,30 @@ snapshots:
dependencies:
glob: 7.2.3
rollup@4.39.0:
rollup@4.40.0:
dependencies:
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.39.0
'@rollup/rollup-android-arm64': 4.39.0
'@rollup/rollup-darwin-arm64': 4.39.0
'@rollup/rollup-darwin-x64': 4.39.0
'@rollup/rollup-freebsd-arm64': 4.39.0
'@rollup/rollup-freebsd-x64': 4.39.0
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
'@rollup/rollup-linux-arm64-gnu': 4.39.0
'@rollup/rollup-linux-arm64-musl': 4.39.0
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-musl': 4.39.0
'@rollup/rollup-linux-s390x-gnu': 4.39.0
'@rollup/rollup-linux-x64-gnu': 4.39.0
'@rollup/rollup-linux-x64-musl': 4.39.0
'@rollup/rollup-win32-arm64-msvc': 4.39.0
'@rollup/rollup-win32-ia32-msvc': 4.39.0
'@rollup/rollup-win32-x64-msvc': 4.39.0
'@rollup/rollup-android-arm-eabi': 4.40.0
'@rollup/rollup-android-arm64': 4.40.0
'@rollup/rollup-darwin-arm64': 4.40.0
'@rollup/rollup-darwin-x64': 4.40.0
'@rollup/rollup-freebsd-arm64': 4.40.0
'@rollup/rollup-freebsd-x64': 4.40.0
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
'@rollup/rollup-linux-arm64-gnu': 4.40.0
'@rollup/rollup-linux-arm64-musl': 4.40.0
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-musl': 4.40.0
'@rollup/rollup-linux-s390x-gnu': 4.40.0
'@rollup/rollup-linux-x64-gnu': 4.40.0
'@rollup/rollup-linux-x64-musl': 4.40.0
'@rollup/rollup-win32-arm64-msvc': 4.40.0
'@rollup/rollup-win32-ia32-msvc': 4.40.0
'@rollup/rollup-win32-x64-msvc': 4.40.0
fsevents: 2.3.3
rope-sequence@1.3.2: {}
@@ -10378,7 +10378,7 @@ snapshots:
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -10390,19 +10390,19 @@ snapshots:
- supports-color
- terser
vite-plugin-ruby@5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
vite-plugin-ruby@5.0.0(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
fast-glob: 3.3.2
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- supports-color
vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.39.0
rollup: 4.40.0
optionalDependencies:
'@types/node': 22.7.0
fsevents: 2.3.3
@@ -10412,7 +10412,7 @@ snapshots:
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
dependencies:
'@vitest/expect': 3.0.5
'@vitest/mocker': 3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/mocker': 3.0.5(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -10428,7 +10428,7 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
why-is-node-running: 2.3.0
optionalDependencies:
Binary file not shown.
@@ -28,6 +28,31 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response.body).to include(agent_bot.access_token.token)
expect(response.body).not_to include(global_bot.access_token.token)
end
it 'properly differentiates between system bots and account bots' do
global_bot = create(:agent_bot)
get "/api/v1/accounts/#{account.id}/agent_bots",
headers: agent.create_new_auth_token,
as: :json
response_data = response.parsed_body
# Find the global bot in the response
global_bot_response = response_data.find { |bot| bot['id'] == global_bot.id }
# Find the account bot in the response
account_bot_response = response_data.find { |bot| bot['id'] == agent_bot.id }
# Verify system_bot attribute and outgoing_url for global bot
expect(global_bot_response['system_bot']).to be(true)
expect(global_bot_response).not_to include('outgoing_url')
# Verify account bot has system_bot attribute false and includes outgoing_url
expect(account_bot_response['system_bot']).to be(false)
expect(account_bot_response).to include('outgoing_url')
# Verify both bots have thumbnail field
expect(global_bot_response).to include('thumbnail')
expect(account_bot_response).to include('thumbnail')
end
end
end
@@ -60,6 +85,10 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(global_bot.name)
expect(response.body).not_to include(global_bot.access_token.token)
# Test for system_bot attribute and webhook URL not being exposed
expect(response.parsed_body['system_bot']).to be(true)
expect(response.parsed_body).not_to include('outgoing_url')
end
end
end
@@ -142,7 +171,7 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response.body).not_to include(global_bot.access_token.token)
end
it 'updates avatar' do
it 'updates avatar and includes thumbnail in response' do
# no avatar before upload
expect(agent_bot.avatar.attached?).to be(false)
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
@@ -153,6 +182,9 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
agent_bot.reload
expect(agent_bot.avatar.attached?).to be(true)
# Verify thumbnail is included in the response
expect(response.parsed_body).to include('thumbnail')
end
it 'updated avatar with avatar_url' do
-19
View File
@@ -1,19 +0,0 @@
require 'rails_helper'
RSpec.describe AgentBots::CsmlJob do
it 'runs csml processor service' do
event = 'message.created'
message = create(:message)
agent_bot = create(:agent_bot)
processor = double
allow(Integrations::Csml::ProcessorService).to receive(:new).and_return(processor)
allow(processor).to receive(:perform)
described_class.perform_now(event, agent_bot, message)
expect(Integrations::Csml::ProcessorService)
.to have_received(:new)
.with(event_name: event, agent_bot: agent_bot, event_data: { message: message })
end
end
-99
View File
@@ -1,99 +0,0 @@
require 'rails_helper'
describe CsmlEngine do
it 'raises an exception if host and api is absent' do
expect { described_class.new }.to raise_error(StandardError)
end
context 'when CSML_BOT_HOST & CSML_BOT_API_KEY is present' do
before do
create(:installation_config, { name: 'CSML_BOT_HOST', value: 'https://csml.chatwoot.dev' })
create(:installation_config, { name: 'CSML_BOT_API_KEY', value: 'random_api_key' })
end
let(:csml_request) { double }
context 'when status is called' do
it 'returns api response if client response is valid' do
allow(HTTParty).to receive(:get).and_return(csml_request)
allow(csml_request).to receive(:success?).and_return(true)
allow(csml_request).to receive(:parsed_response).and_return({ 'engine_version': '1.11.1' })
response = described_class.new.status
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
expect(csml_request).to have_received(:success?)
expect(csml_request).to have_received(:parsed_response)
expect(response).to eq({ 'engine_version': '1.11.1' })
end
it 'returns error if client response is invalid' do
allow(HTTParty).to receive(:get).and_return(csml_request)
allow(csml_request).to receive(:success?).and_return(false)
allow(csml_request).to receive(:code).and_return(401)
allow(csml_request).to receive(:parsed_response).and_return({ 'error': true })
response = described_class.new.status
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
expect(csml_request).to have_received(:success?)
expect(response).to eq({ error: { 'error': true }, status: 401 })
end
end
context 'when run is called' do
it 'returns api response if client response is valid' do
allow(HTTParty).to receive(:post).and_return(csml_request)
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
allow(csml_request).to receive(:success?).and_return(true)
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
response = described_class.new.run({ flow: 'default' }, { client: 'client', payload: { id: 1 }, metadata: {} })
payload = {
bot: { flow: 'default' },
event: {
request_id: 'xxxx-yyyy-wwww-cccc',
client: 'client',
payload: { id: 1 },
metadata: {},
ttl_duration: 4000
}
}
expect(HTTParty).to have_received(:post)
.with(
'https://csml.chatwoot.dev/run', {
body: payload.to_json,
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
}
)
expect(csml_request).to have_received(:success?)
expect(csml_request).to have_received(:parsed_response)
expect(response).to eq({ 'success': true })
end
end
context 'when validate is called' do
it 'returns api response if client response is valid' do
allow(HTTParty).to receive(:post).and_return(csml_request)
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
allow(csml_request).to receive(:success?).and_return(true)
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
payload = { flow: 'default' }
response = described_class.new.validate(payload)
expect(HTTParty).to have_received(:post)
.with(
'https://csml.chatwoot.dev/validate', {
body: payload.to_json,
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
}
)
expect(csml_request).to have_received(:success?)
expect(csml_request).to have_received(:parsed_response)
expect(response).to eq({ 'success': true })
end
end
end
end
@@ -1,108 +0,0 @@
require 'rails_helper'
describe Integrations::Csml::ProcessorService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:agent_bot) { create(:agent_bot, :skip_validate, bot_type: 'csml', account: account) }
let(:agent_bot_inbox) { create(:agent_bot_inbox, agent_bot: agent_bot, inbox: inbox, account: account) }
let(:conversation) { create(:conversation, account: account, status: :pending) }
let(:message) { create(:message, account: account, conversation: conversation) }
let(:event_name) { 'message.created' }
let(:event_data) { { message: message } }
describe '#perform' do
let(:csml_client) { double }
let(:processor) { described_class.new(event_name: event_name, agent_bot: agent_bot, event_data: event_data) }
before do
allow(CsmlEngine).to receive(:new).and_return(csml_client)
end
context 'when a conversation is completed from CSML' do
it 'open the conversation and handsoff it to an agent' do
csml_response = ActiveSupport::HashWithIndifferentAccess.new(conversation_end: true)
allow(csml_client).to receive(:run).and_return(csml_response)
processor.perform
expect(conversation.reload.status).to eql('open')
end
end
context 'when a new message is returned from CSML' do
it 'creates a text message' do
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
messages: [
{ payload: { content_type: 'text', content: { text: 'hello payload' } } }
]
)
allow(csml_client).to receive(:run).and_return(csml_response)
processor.perform
expect(conversation.messages.last.content).to eql('hello payload')
end
it 'creates a question message' do
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
messages: [{
payload: {
content_type: 'question',
content: { title: 'Question Payload', buttons: [{ content: { title: 'Q1', payload: 'q1' } }] }
}
}]
)
allow(csml_client).to receive(:run).and_return(csml_response)
processor.perform
expect(conversation.messages.last.content).to eql('Question Payload')
expect(conversation.messages.last.content_type).to eql('input_select')
expect(conversation.messages.last.content_attributes).to eql({ items: [{ title: 'Q1', value: 'q1' }] }.with_indifferent_access)
end
end
context 'when conversation status is not pending' do
let(:conversation) { create(:conversation, account: account, status: :open) }
it 'returns nil' do
expect(processor.perform).to be_nil
end
end
context 'when message is private' do
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
it 'returns nil' do
expect(processor.perform).to be_nil
end
end
context 'when message type is template (not outgoing or incoming)' do
let(:message) { create(:message, account: account, conversation: conversation, message_type: :template) }
it 'returns nil' do
expect(processor.perform).to be_nil
end
end
context 'when message updated' do
let(:event_name) { 'message.updated' }
context 'when content_type is input_select' do
let(:message) do
create(:message, account: account, conversation: conversation, private: true,
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
end
it 'returns submitted value for message content' do
expect(processor.send(:message_content, message)).to eql('selected_gas')
end
end
context 'when content_type is not input_select' do
let(:message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content_type: :text) }
let(:event_name) { 'message.updated' }
it 'returns nil' do
expect(processor.perform).to be_nil
end
end
end
end
end
@@ -37,15 +37,6 @@ describe AgentBotListener do
listener.message_created(event)
end
end
context 'when agent bot csml type is configured' do
it 'sends message to agent bot' do
agent_bot_csml = create(:agent_bot, :skip_validate, bot_type: 'csml')
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot_csml)
expect(AgentBots::CsmlJob).to receive(:perform_later).with('message.created', agent_bot_csml, message).once
listener.message_created(event)
end
end
end
describe '#webwidget_triggered' do
@@ -153,6 +153,74 @@ RSpec.describe ConversationReplyMailer do
it 'updates the source_id' do
expect(mail.message_id).to eq message.source_id
end
context 'with email attachments' do
it 'includes small attachments as email attachments' do
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with small attachment')
attachment = message_with_attachment.attachments.new(account_id: account.id, file_type: :file)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
attachment.save!
mail = described_class.email_reply(message_with_attachment).deliver_now
# Should be attached to the email
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
# Should not be in large_attachments
expect(mail.body.encoded).not_to include('Attachments:')
end
it 'renders large attachments as links in the email body' do
message_with_large_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with large attachment')
attachment = message_with_large_attachment.attachments.new(account_id: account.id, file_type: :file)
attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf', content_type: 'application/pdf')
attachment.save!
mail = described_class.email_reply(message_with_large_attachment).deliver_now
# Should NOT be attached to the email
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
# Should be rendered as a link in the body
expect(mail.body.encoded).to include('Attachments:')
expect(mail.body.encoded).to include('large_file.pdf')
# Should render a link with large_file.pdf as the link text
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
# Small file should not be rendered as a link in the body
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
it 'handles both small and large attachments correctly' do
message_with_mixed_attachments = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with mixed attachments')
# Small attachment
small_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
small_attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
small_attachment.save!
# Large attachment
large_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
large_attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf',
content_type: 'application/pdf')
large_attachment.save!
mail = described_class.email_reply(message_with_mixed_attachments).deliver_now
# Small file should be attached
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
# Large file should NOT be attached
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
# Large file should be rendered as a link in the body
expect(mail.body.encoded).to include('Attachments:')
expect(mail.body.encoded).to include('large_file.pdf')
# Should render a link with large_file.pdf as the link text
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
# Small file should not be rendered as a link in the body
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
end
end
context 'when smtp enabled for email channel' do
+19
View File
@@ -39,4 +39,23 @@ RSpec.describe AgentBot do
expect(message.reload.sender).to be_nil
end
end
describe '#system_bot?' do
context 'when account_id is nil' do
let(:agent_bot) { create(:agent_bot, account_id: nil) }
it 'returns true' do
expect(agent_bot.system_bot?).to be true
end
end
context 'when account_id is present' do
let(:account) { create(:account) }
let(:agent_bot) { create(:agent_bot, account: account) }
it 'returns false' do
expect(agent_bot.system_bot?).to be false
end
end
end
end
@@ -1,25 +0,0 @@
require 'rails_helper'
describe AgentBots::ValidateBotService do
describe '#perform' do
it 'returns true if bot_type is not csml' do
agent_bot = create(:agent_bot)
valid = described_class.new(agent_bot: agent_bot).perform
expect(valid).to be true
end
it 'returns true if validate csml returns true' do
agent_bot = create(:agent_bot, :skip_validate, bot_type: 'csml', bot_config: {})
csml_client = double
csml_response = double
allow(CsmlEngine).to receive(:new).and_return(csml_client)
allow(csml_client).to receive(:validate).and_return(csml_response)
allow(csml_response).to receive(:blank?).and_return(false)
allow(csml_response).to receive(:[]).with('valid').and_return(true)
valid = described_class.new(agent_bot: agent_bot).perform
expect(valid).to be true
expect(CsmlEngine).to have_received(:new)
end
end
end