Compare commits

...
144 Commits
Author SHA1 Message Date
Sojan Jose 2bd1d88d50 Merge branch 'release/4.11.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-17 15:40:39 -08:00
Sojan Jose e8152642f2 Bump version to 4.11.0 2026-02-17 15:35:20 -08:00
dae4f3ee13 fix: move llm call of captain outside transaction (#13559)
# 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:

The LLM call was wrapped in a transaction. This is an anti-pattern and
caused idle-connections which PG eventually terminated with
`PQconsumeInput() FATAL: terminating connection due to
idle-in-transaction timeout`

This resulted in activity messages being missing in some conversations
on captain handoff, failures queueing up for retry and captain
responding long after conversation was marked open/snoozed.

## Type of change

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

## How Has This Been Tested?

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


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-17 18:12:14 +05:30
e75e8a77f6 feat(shopify): Add mandatory compliance webhooks with HMAC verification (#13549)
Fixes
https://linear.app/chatwoot/issue/CW-6494/add-shopify-mandatory-compliance-webhooks-for-app-store-listing

Shopify requires all public apps to handle three GDPR compliance
webhooks before they can be listed on the App Store. Their automated
review checks for these endpoints and verifies that apps validate HMAC
signatures on incoming requests. We were failing both checks.

This PR adds a single webhook endpoint at `POST /webhooks/shopify` that
receives all three compliance events. When Shopify sends a webhook, it
signs the payload with our app's client secret and includes the
signature in the `X-Shopify-Hmac-SHA256` header. Our controller reads
the raw body, computes the expected HMAC-SHA256 digest, and rejects
mismatched requests with a 401.

Shopify identifies the event type through the `X-Shopify-Topic` header.
For `customers/data_request` and `customers/redact`, we simply
acknowledge with a 200—Chatwoot doesn't persist any Shopify customer
data. All order lookups happen as live API calls at query time. For
`shop/redact`, which Shopify sends after a merchant uninstalls the app,
we delete the integration hook for that shop domain and remove the
stored access token and configuration.


### How to test via Rails console
```
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
body = '{"shop_domain":"test.myshopify.com"}'
valid_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, body))
```

  #### Test 1: No HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "401"
```
  ####  Test 2: Invalid HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => 'invalid', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "401"
```
  ####  Test 3: Valid HMAC, customers/data_request → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "200"
```

####  Test 4: Valid HMAC, customers/redact → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/redact' }
app.response.code  # => "200"
```

#### Test 5: Valid HMAC, shop/redact → 200 (deletes hook)
```  
# First check if a hook exists for this domain:
Integrations::Hook.where(app_id: 'shopify', reference_id: 'test.myshopify.com').count
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'shop/redact' }
app.response.code  # => "200"
```

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 16:52:13 +05:30
229f56d6e3 chore: Remove vue-multiselect and migrate to next components (#13506)
# Pull Request Template

## Description

This PR includes:
1. Removes multiselect usage from the Merge Contact modal (Conversation
sidebar) and replaces it with the existing component used on the Contact
Details page.
2. Replaces legacy form and multiselect elements in Add and Edit
automations flows with next components.**(Also check Macros)**
3. Replace multiselect with ComboBox in contact form country field.
4. Replace multiselect with TagInput in create/edit attribute form.
5. Replace multiselect with TagInput for agent selection in inbox
creation.
6. Replace multiselect with ComboBox in Facebook channel page selection

## Type of change

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

## How Has This Been Tested?

**Screenshots**

1. **Merge modal**
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/a05a96ec-0692-4d94-9e27-d3e85fd143e4"
/>
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/fc1dc977-689d-4440-869d-2124e4ca9083"
/>

2. **Automations**
<img width="849" height="1089" alt="image"
src="https://github.com/user-attachments/assets/b0155f06-ab21-4f90-a2c8-5bfbd97b08f7"
/>
<img width="813" height="879" alt="image"
src="https://github.com/user-attachments/assets/0921ac4a-88f5-49ac-a776-cc02941b479c"
/>
<img width="849" height="826" alt="image"
src="https://github.com/user-attachments/assets/44358dae-a076-4e10-b7ba-a4e40ccd817f"
/>

3. **Country field**
<img width="462" height="483" alt="image"
src="https://github.com/user-attachments/assets/d5db9aa1-b859-4327-9960-957d7091678f"
/>

4. **Add/Edit attribute form**
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/6ab2ea94-73e5-40b8-ac29-399c0543fa7b"
/>
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/b4c5bb0e-baa0-4ef7-a6a2-adb0f0203243"
/>
<img width="635" height="731" alt="image"
src="https://github.com/user-attachments/assets/74890c80-b213-4567-bf5f-4789dda39d2d"
/>

5. **Agent selection in inbox creation**
<img width="635" height="534" alt="image"
src="https://github.com/user-attachments/assets/0003bad1-1a75-4f20-b014-587e1c19a620"
/>
<img width="809" height="602" alt="image"
src="https://github.com/user-attachments/assets/5e7ab635-7340-420a-a191-e6cd49c02704"
/>

7. **Facebook channel page selection**
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/f7ec8d84-0a7d-4bc6-92a1-a1365178e319"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/d0596c4d-94c1-4544-8b50-e7103ff207a6"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/be097921-011b-4dbe-b5f1-5d1306e25349"
/>



## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 16:40:12 +05:30
Aakash BakhleandGitHub 138840a23f fix: typo in metadata key in captain v2 (#13558)
# Pull Request Template

## Description

## Type of change

typo fix

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

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-02-17 15:40:50 +05:30
Sivin VargheseandGitHub c5f6844877 fix: Disable reply editor outside WhatsApp reply window (#13454) 2026-02-17 14:07:36 +05:30
Shivam MishraandGitHub 39243b9e71 fix: duplicate message_created webhooks for WhatsApp messages (#13523)
Some customers using WhatsApp inboxes with account-level webhooks were
reporting receiving duplicate `message_created` webhook deliveries for
every incoming message. Upon inspection, here's what we found

- Both payloads are identical.
- No errors appear in the application logs
- Webhook URL is only configured in one place. 

This meant, the system was sending the webhooks twice. For some context,
there's a know related issue... Meta's WhatsApp Business API can deliver
the same webhook notification multiple times for a single message. The
codebase already acknowledges this — there's a comment in
`IncomingMessageBaseService#process_messages` noting that "multiple
webhook events can be received against the same message due to
misconfigurations in the Meta business manager account." A deduplication
guard exists, but it doesn't actually work under concurrency.

### Rationale

The existing dedup was a three-step sequence: check Redis (`GET`), check
the database, then set a Redis flag (`SETEX`). Two Sidekiq workers
processing duplicate Meta webhooks simultaneously would both complete
the `GET` before either executed the `SETEX`, so both would proceed to
create a message. The `source_id` column has a non-unique index, so the
database wouldn't catch the duplicate either. Each message then
independently fires `after_create_commit`, dispatching two
`message_created` webhook events to the customer.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis GET key ──► nil               Redis GET key ──► nil
                │                                 │
                │    ◄── both pass guard ──►      │
                │                                 │
                ▼                                 ▼
        Redis SETEX key                    Redis SETEX key
                │                                 │
                ▼                                 ▼
        BEGIN transaction               BEGIN transaction
        INSERT message                   INSERT message
        DELETE Redis key ◄─┐                      │
        COMMIT             │             DELETE Redis key
                           │             COMMIT
                           │                      │
                           └── key gone before ───┘
                              B's commit lands

                ▼                                 ▼
        after_create_commit              after_create_commit
        dispatch MESSAGE_CREATED         dispatch MESSAGE_CREATED
                │                                 │
                ▼                                 ▼
        WebhookJob ──► n8n               WebhookJob ──► n8n
                    (duplicate!)
```

There was a second, subtler problem visible in the diagram: the Redis
key was cleared *inside* the database transaction, before the
transaction committed. This opened a window where neither the Redis
check nor the database check would see the in-flight message.

The fix collapses the check-and-set into a single `SET NX EX` call,
which is atomic in Redis. The key is no longer eagerly cleared — it
expires naturally after 24 hours. The database lookup
(`find_message_by_source_id`) remains as a fallback for messages that
were created before the lock expired.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis SET NX ──► OK              Redis SET NX ──► nil
                │                                 │
                ▼                                 ▼
        proceeds to create              returns early
        message normally                (lock already held)
```

### Implementation Notes

The lock logic is extracted into `Whatsapp::MessageDedupLock`, a small
class that wraps a single `Redis SET NX EX` call. This makes the
concurrency guarantee testable in isolation — the spec uses a
`CyclicBarrier` to race two threads against the same key and asserts
exactly one wins, without needing database writes,
`use_transactional_tests = false`, or monkey-patching.

Because the Redis lock now persists (instead of being cleared
mid-transaction), existing WhatsApp specs needed an `after` hook to
clean up `MESSAGE_SOURCE_KEY::*` keys between examples. Transactional
fixtures only roll back the database, not Redis.
2026-02-17 14:01:10 +05:30
Sivin VargheseandGitHub fb2f5e1d42 fix: Persist compose form state on accidental outside click (#13529) 2026-02-17 13:57:44 +05:30
Sivin VargheseandGitHub cfe3061b5d feat: Allow removing labels via conversation context menu (#13525)
# Pull Request Template

## Description

This PR adds support for removing labels from the conversation card
context menu. Assigned labels now show a checkmark, and clicking an
already-selected label will remove it.

Fixes
https://linear.app/chatwoot/issue/CW-6400/allow-removing-labels-directly-from-the-right-click-menu
https://github.com/chatwoot/chatwoot/issues/13367
## Type of change

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

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/4e3a6080-a67d-4851-9d10-d8dbf3ceeb04




## 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
2026-02-17 13:30:55 +05:30
Aakash BakhleandGitHub aa7e3c2d38 feat: langfuse logging improvements (#13534)
Langfuse logging improvements

## 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)
For reply suggestion: the errors are being stored inside output field,
but observations should be marked as errors.
For assistant: add credit_used metadata to filter handoffs from
ai-replies
For langfuse tool call: add `observation_type=tool`

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?

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

before:
<img width="1028" height="57" alt="image"
src="https://github.com/user-attachments/assets/70f6a36e-6c33-444c-a083-723c7c9e823a"
/>

after:
<img width="872" height="69" alt="image"
src="https://github.com/user-attachments/assets/1b6b6f5f-5384-4e9c-92ba-f56748fec6dd"
/>

`credit_used` to filter handoffs from AI replies that cause credit usage
<img width="1082" height="672" alt="image"
src="https://github.com/user-attachments/assets/90914227-553a-4c03-bc43-56b2018ac7c1"
/>


set `observation_type` to `tool`
<img width="726" height="1452" alt="image"
src="https://github.com/user-attachments/assets/e639cc9b-1c6c-4427-887e-23e5523bf64f"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-02-17 13:30:04 +05:30
3874383698 feat: insrument captain v2 (#13439)
# Pull Request Template

## Description

Instruments captain v2

## Type of change

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

## How Has This Been Tested?

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

Local testing:
<img width="864" height="510" alt="image"
src="https://github.com/user-attachments/assets/855ebce5-e8b8-4d22-b0bb-0d413769a6ab"
/>



## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 13:28:26 +05:30
Aakash BakhleandGitHub 101eca3003 feat: add captain editor events (#13524)
## Description

Adds missing analytics instrumentation for the editor AI funnel so we
can measure end-to-end usage and outcome quality.

### What was added

- Captain: Editor AI menu opened
- Captain: Generation failed
- Captain: AI-assisted message sent

### Behavior covered

- Tracks AI button click + menu open from both entry points:
    - top panel sparkle button
    - inline editor copilot button
- Tracks generation failures (initial + follow-up stages).
- Tracks whether accepted AI content was sent as-is or edited before
send.

### Notes

- Applies to editor Captain accept/send flow
(rewrite/summarize/reply_suggestion + follow-ups).
- Does not change Copilot sidebar flow instrumentation.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] 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?

### Manual verification steps

<img width="1906" height="832" alt="image"
src="https://github.com/user-attachments/assets/f0ade43b-aa8d-41be-8ca2-20a091a81f60"
/>

<img width="828" height="280" alt="image"
src="https://github.com/user-attachments/assets/be76219e-fb61-4a6e-bff5-dc085b0a3cc9"
/>

<img width="415" height="147" alt="image"
src="https://github.com/user-attachments/assets/36802c5c-33a7-49ed-bf7e-f0b02d86dccc"
/>

<img width="2040" height="516" alt="image"
src="https://github.com/user-attachments/assets/74b95288-bc86-4312-a282-14211ae8f25c"
/>


1. Open a conversation with Captain tasks enabled.
2. Click AI button in top panel and inline editor.
3. Confirm analytics events fire for:
    - AI menu opened
4. Run an AI action and force a failure scenario (or empty response
path) and confirm generation-failed event.
5. Accept AI output, then:
    - send without changes -> editedBeforeSend: false
    - edit then send -> editedBeforeSend: true

## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-02-17 13:26:56 +05:30
Sojan JoseandGitHub 61eaa098ae fix(messages): reduce audio transcription 400 retry noise (#13487)
## Summary
This PR reduces duplicate failure noise for audio transcription jobs
that fail with permanent HTTP 400 responses, and fixes a file-format
edge case causing intermittent 400s.

Sentry issue: [CHATWOOT-99E /
6660541334](https://chatwoot-p3.sentry.io/issues/6660541334/)

## Confirmed root cause
For some attachments, the stored filename had no extension (example:
`speech`, content type `audio/mpeg`).
When the temporary transcription upload file was created without an
extension, OpenAI returned:
`Unrecognized file format` (HTTP 400).

## Scope of changes
1. `Messages::AudioTranscriptionJob`
- Keeps `discard_on Faraday::BadRequestError` to avoid retry storms on
permanent request errors.
- Adds explicit Rails warning logs for discarded jobs with
attachment/job/status context.

2. `Messages::AudioTranscriptionService`
- Keeps guaranteed temp file cleanup via `ensure`.
- Ensures temp upload files include an extension when the original
filename has none, derived from blob `content_type`.
- This addresses intermittent failures like extensionless `audio/mpeg`
files.

## Reproduction
Enable audio transcription for an account and process an audio
attachment whose stored filename has no extension (for example `speech`)
but valid audio content type (`audio/mpeg`).
Before this fix, OpenAI transcription could return HTTP 400
`Unrecognized file format` for that attachment while similar attachments
with extensions succeeded.

## Testing
Ran:
`bundle exec rubocop
enterprise/app/jobs/messages/audio_transcription_job.rb
enterprise/app/services/messages/audio_transcription_service.rb`

Result: both modified files pass lint with no offenses.
2026-02-17 13:25:13 +05:30
9cd7c4ef89 fix: Enhance notification emails with message details and handle failed messages (#13273)
## Description

Handle messages with null content properly in UI and email notifications

## Type of change

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

## Relevant Screenshots:
<img width="688" height="765" alt="Screenshot 2026-01-21 at 4 43 00 PM"
src="https://github.com/user-attachments/assets/6a27c22e-2ae6-4377-a05d-cfa44bf181fe"
/>


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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches notification email templates and message rendering conditions;
mistakes could lead to missing content/attachments in emails or
incorrect UI visibility, but changes are localized and non-auth/security
related.
> 
> **Overview**
> Agent notification emails for *assigned* and *participating* new
messages now include the actual message details (sender name, rendered
text when present, and attachment links) and gracefully fall back when
content is unavailable.
> 
> To support this, the mailer now passes `@message` into Liquid via
`MessageDrop` (adding `attachments` URLs), and the dashboard message UI
now renders failed/external-error messages even when `content` is `null`
while tightening retry eligibility to require content or attachments
(and still within 1 day).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
475c8cedda54eb5e806990f977faf8098d0b27d8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-16 14:47:33 +05:30
Tanmay Deep SharmaandGitHub f4538ae2c5 fix: Enforce team boundaries to prevent cross-team assignments (#13353)
## Description

Fixes a critical bug where conversations assigned to a team could be
auto-assigned to agents outside that team when all team members were at
capacity.

## Type of change

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

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core assignment selection for both legacy and v2 flows;
misconfiguration of `allow_auto_assign` or team membership could cause
conversations to remain unassigned.
> 
> **Overview**
> Prevents auto-assignment from crossing team boundaries by filtering
eligible agents to the conversation’s `team` members (and requiring
`team.allow_auto_assign`) in both the legacy `AutoAssignmentHandler`
path and the v2 `AutoAssignment::AssignmentService` (including the
Enterprise override).
> 
> Adds test coverage to ensure team-scoped conversations only assign to
team members, and are skipped when team auto-assign is disabled or no
team members are available; also updates the conversations controller
spec setup to include team membership.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
67ed2bda0cd8ffd56c7e0253b86369dead2e6155. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-16 14:39:20 +05:30
Sojan JoseandGitHub fd5ac2a8a3 fix: apply installation branding replacement in tooltip copy (#13538)
## Summary
Fix hardcoded `Chatwoot` branding in two UI tooltips using the existing
`useBranding` flow so self-hosted/white-label deployments no longer show
the wrong brand text.

## Changes
- LabelSuggestion tooltip now uses:
  - `replaceInstallationName($t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'))`
- Message avatar tooltip (native app/external echo) now uses:
  - `replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'))`

## Why
This follows the existing branding pattern already used in the product
and keeps behavior consistent across deployments.

## Notes
- No change to message logic or API behavior.
- `AGENTS.md` updated with a branding guidance note.

## Fixes
- Fixes https://github.com/chatwoot/chatwoot/issues/13306
- Fixes https://github.com/chatwoot/chatwoot/issues/13466

## Testing

<img width="195" height="155" alt="Screenshot 2026-02-13 at 3 55 39 PM"
src="https://github.com/user-attachments/assets/5b295cdd-6e5d-42c0-bbd7-23ba7052e1c3"
/>
<img width="721" height="152" alt="Screenshot 2026-02-13 at 3 55 48 PM"
src="https://github.com/user-attachments/assets/19cec2a0-451f-4fb3-bd61-7c2e591fc3c7"
/>
2026-02-13 16:47:25 -08:00
Sojan JoseandGitHub 6b7180d051 fix(twilio): prevent dead jobs on missing channel lookup (#13522)
## Why
We observed `Webhooks::TwilioEventsJob` failures ending up in Sidekiq
dead jobs when Twilio callback payloads could not be mapped to a
`Channel::TwilioSms` record. In this scenario, channel lookup raised
`ActiveRecord::RecordNotFound`, which caused retries and eventual dead
jobs instead of a graceful drop.

Related Sentry issue/search:
-
https://chatwoot-p3.sentry.io/issues/?project=6382945&query=Webhooks%3A%3ATwilioEventsJob%20ActiveRecord%3A%3ARecordNotFound

## What changed
This PR keeps the existing lookup flow but makes it non-raising:
- `app/services/twilio/incoming_message_service.rb`
  - `find_by!` -> `find_by` for account SID + phone lookup
  - Added warning log when channel lookup misses
- `app/services/twilio/delivery_status_service.rb`
  - `find_by!` -> `find_by` for account SID + phone lookup
  - Added warning log when channel lookup misses

## Reproduction
Configure a Twilio webhook callback that reaches Chatwoot but does not
match an existing Twilio channel lookup path. Before this change, the
job raises `RecordNotFound` and can end up in dead jobs after retries.
After this change, the job logs the miss and exits safely.

## Testing
- `bundle exec rspec
spec/services/twilio/incoming_message_service_spec.rb
spec/services/twilio/delivery_status_service_spec.rb`
- `bundle exec rubocop app/services/twilio/incoming_message_service.rb
app/services/twilio/delivery_status_service.rb`
2026-02-13 14:06:12 -08:00
João Pedro Baza Garcia RodriguesandGitHub 4d362da9f0 fix: Prevent user enumeration on password reset endpoint (#13528)
## Description

The current password reset endpoint returns different HTTP status codes
and messages depending on whether the email exists in the system (200
for existing emails, 404 for non-existing ones). This allows attackers
to enumerate valid email addresses via the password reset form.

## Changes

### `app/controllers/devise_overrides/passwords_controller.rb`
- Removed the `if/else` branch that returned different responses based
on email existence
- Now always returns a generic `200 OK` response with the same message
regardless of whether the email exists
- Uses safe navigation operator (`&.`) to send reset instructions only
if the user exists

### `config/locales/en.yml`
- Consolidated `reset_password_success` and `reset_password_failure`
into a single generic `reset_password` key
- New message does not reveal whether the email exists in the system

## Security Impact
- **Before**: An attacker could determine if an email was registered by
observing the HTTP status code (200 vs 404) and response message
- **After**: All requests receive the same 200 response with a generic
message, preventing user enumeration

This follows [OWASP guidelines for authentication error
messages](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses).

Fixes #13527
2026-02-13 13:45:40 +05:30
2c2f0547f7 fix: Captain not responding to campaign conversations (#13489)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-02-12 10:07:56 +05:30
Sojan JoseandGitHub c7193c7917 fix(slack): handle archived channel errors in SendOnSlackJob (#13520)
When a Slack-integrated channel is archived, posting from Chatwoot
raises `Slack::Web::Api::Errors::IsArchived` in `SendOnSlackJob`, which
retries and can end up in dead jobs. This can be reproduced by archiving
the connected Slack channel for a valid hook and creating outgoing
messages. This change adds `IsArchived` to the existing handled Slack
API rescue path in
`Integrations::Slack::SendOnSlackService#send_message`, so
archived-channel failures now follow the same flow as related Slack
failures (`prompt_reauthorization!` + `disable`) instead of bubbling and
retrying repeatedly. I tested this by running `bundle exec rubocop
lib/integrations/slack/send_on_slack_service.rb` (with `rbenv`
initialized), and it passes with no offenses.

Sentry issue: https://chatwoot-p3.sentry.io/issues/7150427066/
2026-02-11 17:05:44 -08:00
Sojan JoseandGitHub d272a64ff7 fix(mailbox): handle malformed sender address headers (#13486)
## How to reproduce
When an inbound email has malformed sender headers (for example `From:
McDonald <info@example.com` without a closing `>`), mailbox
processing can raise `Mail::Field::IncompleteParseError` while resolving
sender data in `MailPresenter`.

## What changed
This PR hardens sender parsing in `MailPresenter` with a small, readable
implementation:
- Added/used a safe parser (`parse_mail_address`) that rescues
`Mail::Field::ParseError` and `Mail::Field::IncompleteParseError`.
- `sender_name` now uses the same safe parser path.
- `original_sender` now resolves candidates in order via a compact
`filter_map` flow:
  - `Reply-To`
  - `X-Original-Sender`
  - `From`
- All three candidates are parsed as email addresses before use
(including `X-Original-Sender`), and invalid values are ignored.
- `notification_email_from_chatwoot?` now compares sender addresses
case-insensitively (`casecmp?`) to avoid case-only mismatches.

## Test coverage
Added focused presenter specs for:
- malformed `From` header returns nil sender values and does not
classify as notification sender
- malformed `Reply-To` falls back to valid `From`
- valid `X-Original-Sender` is used when present
- invalid `X-Original-Sender` falls back to valid `From`
- mixed-case sender address still matches configured
`MAILER_SENDER_EMAIL`

## How this was tested
Ran:
- `bundle exec rspec spec/presenters/mail_presenter_spec.rb`
- `bundle exec rubocop app/presenters/mail_presenter.rb
spec/presenters/mail_presenter_spec.rb`

Sentry issue:
[CHATWOOT-B9Y](https://chatwoot-p3.sentry.io/issues/7005483640/)
2026-02-11 11:02:38 -08:00
Sojan JoseandGitHub b2cb3717e5 fix: Replace default Rails error pages with custom designs (#13514)
## Summary

- Replace generic Rails 404, 422, and 500 error pages with
custom-designed pages
- Each page features a prominent gradient error code, clean typography,
and a "Back to home" CTA
- Full dark mode support via `prefers-color-scheme` media query  
- Mobile responsive design  
- No external dependencies (self-contained static HTML, works even when
Rails is down)
- No logo/branding to ensure compatibility with custom-branded
self-hosted instances

---

## Before (default Rails 404)

<img width="2400" height="1998" alt="old-404"
src="https://github.com/user-attachments/assets/c5f044c4-aab6-40e9-aa11-6096ed9d2b42"
/>

---

## After

### Light mode

| 404 | 422 | 500 |
|-----|-----|-----|
| <img width="600" alt="new-404-light"
src="https://github.com/user-attachments/assets/1826c812-0eb9-4219-bdd2-026c54b53123"
/> | <img width="600" alt="new-422-light"
src="https://github.com/user-attachments/assets/d72ffdbf-b61e-4f53-a16b-4ee81103124f"
/> | <img width="600" alt="new-500-light"
src="https://github.com/user-attachments/assets/81bbdb24-bfe7-43a1-b584-f37f71e3bded"
/> |

---

### Dark mode

| 404 | 422 | 500 |
|-----|-----|-----|
| <img width="600" alt="new-404-dark"
src="https://github.com/user-attachments/assets/bd323915-bfb9-48c1-885d-96ff263b4ae0"
/> | <img width="600" alt="new-422-dark"
src="https://github.com/user-attachments/assets/dcb08eca-aee5-4e36-9690-f44da13e8d88"
/> | <img width="600" alt="new-500-dark"
src="https://github.com/user-attachments/assets/538c3c2c-b9dc-406c-9932-ff8897b64790"
/> |

---

## Test plan

Easier way to verify:

- [ ] Visit `/404.html` directly to verify the 404 page  
- [ ] Visit `/422.html` directly to verify the 422 page  
- [ ] Visit `/500.html` directly to verify the 500 page  

Full verification:

- [ ] Visit a non-existent route in production mode to verify the 404
page
- [ ] Trigger a 422 error (e.g., invalid CSRF token) to verify the 422
page
- [ ] Trigger a 500 error to verify the 500 page  
- [ ] Toggle system dark mode and verify all pages render correctly  
- [ ] Test on mobile viewport widths
2026-02-11 07:57:00 -08:00
Vishnu NarayananandGitHub 00ed074d72 fix: disable email transcript for free plans (#13509)
- Block email transcript functionality for accounts without a paid plan
to prevent SES abuse.
2026-02-11 21:21:36 +05:30
7b512bd00e fix: V2 Assignment service enhancements (#13036)
## Linear Ticket:
https://linear.app/chatwoot/issue/CW-6081/review-feedback

## Description

Assignment V2 Service Enhancements

- Enable Assignment V2 on plan upgrade
- Fix UI issue with fair distribution policy display
- Add advanced assignment feature flag and enhance Assignment V2
capabilities

## Type of change

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

## How Has This Been Tested?

This has been tested using the UI.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes auto-assignment execution paths, rate limiting defaults, and
feature-flag gating (including premium plan behavior), which could
affect which conversations get assigned and when. UI rewires inbox
settings and policy flows, so regressions are possible around
navigation/linking and feature visibility.
> 
> **Overview**
> **Adds a new premium `advanced_assignment` feature flag** and uses it
to gate capacity/balanced assignment features in the UI (sidebar entry,
settings routes, assignment-policy landing cards) and backend
(Enterprise balanced selector + capacity filtering).
`advanced_assignment` is marked premium, included in Business plan
entitlements, and auto-synced in Enterprise accounts when
`assignment_v2` is toggled.
> 
> **Improves Assignment V2 policy UX** by adding an inbox-level
“Conversation Assignment” section (behind `assignment_v2`) that can
link/unlink an assignment policy, navigate to create/edit policy flows
with `inboxId` query context, and show an inbox-link prompt after
creating a policy. The policy form now defaults to enabled, disables the
`balanced` option with a premium badge/message when unavailable, and
inbox lists support click-to-navigate.
> 
> **Tightens/adjusts auto-assignment behavior**: bulk assignment now
requires `inbox.enable_auto_assignment?`, conversation ordering uses the
attached `assignment_policy` priority, and rate limiting uses
`assignment_policy` config with an infinite default limit while still
tracking assignments. Tests and i18n strings are updated accordingly.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-11 12:24:45 +05:30
PranavandGitHub 8f95fafff4 feat: Add a setting to keep conversations pending on bot failures (#13512)
Adds an account-level setting `keep_pending_on_bot_failure` to control
whether conversations should move from pending to open when agent bot
webhooks fail.

Some users experience occasional message drops and don't want
conversations to automatically reopen due to transient bot failures.
This setting gives accounts control over that behavior. This is a
temporary setting which will be removed in future once a proper fix for
it is done, so it is not added in the UI.
2026-02-10 17:27:42 -08:00
0ad47d87f4 fix: Use Faraday for Telegram document uploads to fix large file failures (#13397)
Fixes
https://linear.app/chatwoot/issue/CW-6415/sending-large-attachments-11mb-via-telegram-channels-fails-with-http

 #### Issue
Sending large attachments (~11MB) via Telegram channels fails with HTTP
502 (Bad Gateway) and 413 (Request Entity Too Large) errors. The issue
is caused by HTTParty's built-in multipart encoding, which reads the
entire file into an in-memory string before constructing the request
body. For large files, this produces a malformed multipart request that
Telegram's API proxy rejects.

#### Solution

Replace HTTParty with Faraday + multipart-post (both already available
in the project) for the sendDocument multipart upload. The
multipart-post gem streams file content directly from disk into the HTTP
request, producing a correctly formed multipart body that Telegram
accepts for large files.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-10 14:25:25 -08:00
Sivin VargheseandGitHub e65ea24360 fix: Wrong assignee displayed after switching conversations (#13501) 2026-02-10 15:23:55 +05:30
b252656984 fix: Prevent race condition in conversation dataFetched flag (#13492)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-10 15:23:14 +05:30
Aakash BakhleandGitHub 6e397c7571 fix: default model for captain assistant (#13496) 2026-02-10 14:53:53 +05:30
Sojan JoseandGitHub 4622560fac chore(dev): document codex worktree local setup (#13494)
This PR standardizes local Codex worktree usage with a simple
dynamic-port workflow and ensures local-only artifacts stay out of
version control.

To reproduce: create a Codex worktree, run the setup script from
`.codex/environments/environment.toml`, and verify that it generates
per-worktree DB and port values along with a `Procfile.worktree` for
Overmind.

Changes included:
- Add `.codex/` and `Procfile.worktree` to `.gitignore`
- Document the Codex Worktree Workflow in `AGENTS.md`, outlining
expected local setup conventions

Tested locally by running the setup script with `CODEX_SKIP_INSTALL=1`
and `CODEX_SKIP_DB_PREPARE=1`. Verified successful output, dynamic
`FRONTEND_URL` / Vite port generation, and that `git diff` contains only
the intended documentation and ignore updates.
2026-02-09 20:56:40 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6632610e78 chore(deps): bump faraday from 2.13.1 to 2.14.1 (#13503)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.13.1 to
2.14.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lostisland/faraday/releases">faraday's
releases</a>.</em></p>
<blockquote>
<h2>v2.14.1</h2>
<h2>Security Note</h2>
<p>This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.</p>
<h2>What's Changed</h2>
<ul>
<li>Add comprehensive AI agent guidelines for Claude, Cursor, and GitHub
Copilot by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1642">lostisland/faraday#1642</a></li>
<li>Add RFC document for Options architecture refactoring plan by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1644">lostisland/faraday#1644</a></li>
<li>Bump actions/checkout from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1655">lostisland/faraday#1655</a></li>
<li>Explicit top-level namespace reference by <a
href="https://github.com/c960657"><code>@​c960657</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1657">lostisland/faraday#1657</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1642">lostisland/faraday#1642</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.14.0...v2.14.1">https://github.com/lostisland/faraday/compare/v2.14.0...v2.14.1</a></p>
<h2>v2.14.0</h2>
<h2>What's Changed</h2>
<h3>New features </h3>
<ul>
<li>Use newer <code>UnprocessableContent</code> naming for 422 by <a
href="https://github.com/tylerhunt"><code>@​tylerhunt</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1638">lostisland/faraday#1638</a></li>
</ul>
<h3>Fixes 🐞</h3>
<ul>
<li>Convert strings to UTF-8 by <a
href="https://github.com/c960657"><code>@​c960657</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1624">lostisland/faraday#1624</a></li>
<li>Fix <code>Response#to_hash</code> when response not finished yet by
<a href="https://github.com/yykamei"><code>@​yykamei</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1639">lostisland/faraday#1639</a></li>
</ul>
<h3>Misc/Docs 📄</h3>
<ul>
<li>Lint: use <code>filter_map</code> by <a
href="https://github.com/olleolleolle"><code>@​olleolleolle</code></a>
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1637">lostisland/faraday#1637</a></li>
<li>Bump <code>actions/checkout</code> from v4 to v5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1636">lostisland/faraday#1636</a></li>
<li>Fixes documentation by <a
href="https://github.com/dharamgollapudi"><code>@​dharamgollapudi</code></a>
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1635">lostisland/faraday#1635</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/c960657"><code>@​c960657</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1624">lostisland/faraday#1624</a></li>
<li><a
href="https://github.com/dharamgollapudi"><code>@​dharamgollapudi</code></a>
made their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1635">lostisland/faraday#1635</a></li>
<li><a href="https://github.com/tylerhunt"><code>@​tylerhunt</code></a>
made their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1638">lostisland/faraday#1638</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.13.4...v2.14.0">https://github.com/lostisland/faraday/compare/v2.13.4...v2.14.0</a></p>
<h2>v2.13.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Improve error handling logic and add missing test coverage by <a
href="https://github.com/iMacTia"><code>@​iMacTia</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1633">lostisland/faraday#1633</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.13.3...v2.13.4">https://github.com/lostisland/faraday/compare/v2.13.3...v2.13.4</a></p>
<h2>v2.13.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix type assumption in <code>Faraday::Error</code> by <a
href="https://github.com/iMacTia"><code>@​iMacTia</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1630">lostisland/faraday#1630</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lostisland/faraday/commit/16cbd38ef252d25dedf416a4d2510a2f3db10c87"><code>16cbd38</code></a>
Version bump to 2.14.1</li>
<li><a
href="https://github.com/lostisland/faraday/commit/a6d3a3a0bf59c2ab307d0abd91bc126aef5561bc"><code>a6d3a3a</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/lostisland/faraday/commit/b23f710d28c0dba169470f568df4017a1e8beea7"><code>b23f710</code></a>
Explicit top-level namespace reference (<a
href="https://redirect.github.com/lostisland/faraday/issues/1657">#1657</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/49ba4ac3a7359baed634c12a82386f6c8c717ea8"><code>49ba4ac</code></a>
Bump actions/checkout from 5 to 6 (<a
href="https://redirect.github.com/lostisland/faraday/issues/1655">#1655</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/51a49bc99d7df6f724d250d64771e1d710576df7"><code>51a49bc</code></a>
Ensure Claude reads the guidelines and allow to plan in a gitignored
.ai/PLAN...</li>
<li><a
href="https://github.com/lostisland/faraday/commit/894f65cab8f04bcf35e84a2dfd9fc0286dbce340"><code>894f65c</code></a>
Add RFC document for Options architecture refactoring plan (<a
href="https://redirect.github.com/lostisland/faraday/issues/1644">#1644</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/397e3ded0c5166313bb22f1c0221b36b6023fd0f"><code>397e3de</code></a>
Add comprehensive AI agent guidelines for Claude, Cursor, and GitHub
Copilot ...</li>
<li><a
href="https://github.com/lostisland/faraday/commit/d98c65cfc254ea2898386e4359428527122abec3"><code>d98c65c</code></a>
Update Faraday-specific AI agent guidelines</li>
<li><a
href="https://github.com/lostisland/faraday/commit/56c18ecb718e30c5a3a0dea9bd2361912af9013c"><code>56c18ec</code></a>
Add AI agent guidelines specific to Faraday repository</li>
<li><a
href="https://github.com/lostisland/faraday/commit/3201a42957d37efc968ee8834ba9b50ed5dde54a"><code>3201a42</code></a>
Version bump to 2.14.0</li>
<li>Additional commits viewable in <a
href="https://github.com/lostisland/faraday/compare/v2.13.1...v2.14.1">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 16:12:52 -08:00
Aakash BakhleandGitHub bd732f1fa9 fix: search faqs in account language (#13428)
# Pull Request Template

## Description

Reply suggestions uses `search_documentation`. While this is useful,
there is a subtle bug, a user's message may be in a different language
(say spanish) than the FAQs present (english).
This results in embedding search in spanish and compared against english
vectors, which results in poor retrieval and poor suggestions.


Fixes # (issue)
This PR fixes the above behaviour by making a small llm call translate
the query before searching in the search documentation tool


## Type of change

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

## How Has This Been Tested?

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

before:
<img width="894" height="157" alt="image"
src="https://github.com/user-attachments/assets/83871ee5-511e-4432-8b99-39e803759f63"
/>

after:
<img width="1149" height="294" alt="image"
src="https://github.com/user-attachments/assets/f9617d7a-6d48-4ca1-ad1c-2181e16c1f3d"
/>


test on rails console:
<img width="2094" height="380" alt="image"
src="https://github.com/user-attachments/assets/159fdaa5-8808-49d2-be5d-304d69fa97f7"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-02-09 17:25:11 +05:30
Shivam MishraandGitHub 67112647e8 fix: escape special characters in Linear GraphQL queries (#13490)
Creating a Linear issue from Chatwoot fails with a GraphQL parse error
when the title, description, or search term contains double quotes. For
example, a description like `the sender is "Bot"` produces this broken
query:

```graphql
issueCreate(input: { description: "the sender is "Bot"" })
```

Linear's API rejects this with `Syntax Error: Expected ":", found
String`. This affects issue creation, issue linking, and issue search —
any flow where user-provided text is interpolated into a GraphQL query.

The `graphql_value` helper was only escaping newlines (`\n`) but not
quotes, backslashes, or other characters that are meaningful inside a
GraphQL string literal. On top of that, `issue_link` and `search_issue`
bypassed `graphql_value` entirely, using raw string interpolation
instead.

The fix replaces the manual `gsub` escaping with Ruby's `to_json`, which
produces a properly escaped, double-quoted string that handles all
special characters. This is a minimal, well-understood substitution —
`to_json` on a Ruby string returns a valid JSON string literal, which is
also a valid GraphQL string literal since GraphQL uses the same escaping
rules. The `issue_link` mutation and `search_issue` query are updated to
route their parameters through `graphql_value` instead of raw
interpolation.

The `team_entities_query` and `linked_issues` methods in `queries.rb`
also use raw interpolation, but their inputs are system-generated IDs
and URLs rather than user-provided text, so they're left as-is to keep
this change focused.
2026-02-09 16:18:04 +05:30
Tanmay Deep SharmaandGitHub 04c456e0a3 fix: handle 404 errors gracefully in avatar download job (#13491)
## Description

Fixes `Avatar::AvatarFromUrlJob` logging 404 errors as ERROR when
avatars don't exist

## Type of change

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


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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Small logging-only behavior change that doesn’t affect attachment flow
or persisted data beyond existing sync-attribute updates.
> 
> **Overview**
> Updates `Avatar::AvatarFromUrlJob` error handling to treat
`Down::NotFound` (404/missing avatar URL) as a non-error: it now logs an
INFO message instead of logging as ERROR.
> 
> Other `Down::Error` failures continue to be logged as ERROR, and the
job still runs `update_avatar_sync_attributes` in `ensure`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
675f41041ae3dd4ead6e0dee5f1586dcad9750cd. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-09 13:27:23 +05:30
Sojan JoseandGitHub 656ae41b24 fix(imap): handle IMAP parser/read errors without exception tracking (#13473)
When an IMAP server returns malformed or partial protocol responses,
Inboxes::FetchImapEmailsJob can raise Net::IMAP::ResponseParseError,
Net::IMAP::ResponseReadError, or Net::IMAP::ResponseTooLargeError.
Previously these errors fell through to the generic StandardError
handler and were captured repeatedly in Sentry.

This PR updates app/jobs/inboxes/fetch_imap_emails_job.rb to treat those
parser/read exceptions as handled IMAP failures by adding them to the
existing IMAP/network rescue block, so they are logged and retried on
the next scheduled run without exception tracking noise.

fixes:
https://chatwoot-p3.sentry.io/issues/7132037793/events/104fb9b4d80a4fb6ba3861c44c6c9b83/

How to reproduce:
- Use an IMAP server/inbox that intermittently returns malformed or
truncated protocol responses during search/fetch.
- Run Inboxes::FetchImapEmailsJob for that channel and observe the
raised parser/read exception.

How this was tested:
- bundle exec ruby -c app/jobs/inboxes/fetch_imap_emails_job.rb
- bundle exec rubocop app/jobs/inboxes/fetch_imap_emails_job.rb
2026-02-07 17:30:54 -08:00
Sojan JoseandGitHub f83415f299 fix(account-deletion): normalize deleted email suffix and handle collisions safely (#13472)
## Summary
This PR fixes account deletion failures by changing how orphaned user
emails are rewritten during `AccountDeletionService`.

Ref:
https://chatwoot-p3.sentry.io/issues/6715254765/events/e228a5d045ad47348d6c32448bc33b7a/

## Changes (develop -> this branch)
- Updated soft-delete email rewrite from:
  - `#{original_email}-deleted.com`
- To deterministic value:
  - `#{user.id}@chatwoot-deleted.invalid`
- Added reserved non-deliverable domain constant:
  - `@chatwoot-deleted.invalid`
- Replaced the "other accounts" check from `count.zero?` to `exists?`
(same behavior, cheaper query).
- Updated service spec expectation to match deterministic email value
and assert it differs from original email.

## Files changed
- `app/services/account_deletion_service.rb`
- `spec/services/account_deletion_service_spec.rb`

## How to verify
- Run: `bundle exec rspec
spec/services/account_deletion_service_spec.rb`
- Run: `bundle exec rubocop app/services/account_deletion_service.rb
spec/services/account_deletion_service_spec.rb`
2026-02-07 17:29:27 -08:00
Vishnu NarayananandGitHub 0a910c3763 fix: Add email rate limiting to automation rule actions (#13474) 2026-02-07 10:02:40 -08:00
PranavandGitHub 6a7cbcf5ba fix: Fixes reply-to in WhatsApp Cloud API (#13467)
This change https://github.com/chatwoot/chatwoot/pull/13371 broke the
functionality. When a user replies to a WhatsApp message, the reply
context wasn't being properly stored in Chatwoot due to #13371

WhatsApp sends reply messages with a `context` field containing the
original message ID:
```json
{
    "messages": [{
      "context": {
        "from": "phone_number",
        "id": "wamid.ORIGINAL_MESSAGE_ID"
      },
      "from": "phone_number",
      "id": "wamid.REPLY_MESSAGE_ID",
      "text": { "body": "This is a reply" }
    }]
  }
```
However, the in_reply_to_external_id was being overridden when building
the message because content_attributes was explicitly set to either {
external_echo: true } or {}, which discarded the reply-to information.
2026-02-06 14:01:01 -08:00
0e30e3c00a fix: add loading and silent retry to summary reports (#13455)
For large accounts, summary report queries can take several seconds to
complete, often times hitting the 15-second production request timeout.
The existing implementation silently swallows these failures and
provides no feedback during loading. Users see stale data with no
indication that a fetch is in progress, and if they interact with
filters while a request is in flight, they trigger race conditions that
can result in mismatched data being displayed.

This is a UX-level fix for what is fundamentally a performance problem.
While the underlying query performance is addressed separately, users
need proper feedback either way

## Approach

The PR adds three things: 

1. A loading overlay on the table, to provide feedback on loading state
2. Disabled filter inputs during loading so that the user does not
request new information that can cause race conditions in updating the
store
3. Silent retry before showing an error.

The retry exists because these queries often succeed on the second
attempt—likely due to database query caching. Rather than immediately
showing an error and forcing the user to manually retry, we do it
automatically. If the second attempt also fails, we show a toast so the
user knows something went wrong.

The store previously caught and discarded errors entirely. It now
rethrows them after resetting the loading flag, allowing components to
handle failures as they see fit.

### Previews

#### Double Retry and Error


https://github.com/user-attachments/assets/c189b173-8017-44b7-9493-417d65582c95

#### Loading State


https://github.com/user-attachments/assets/9f899c20-fbad-469b-93cc-f0d05d0853b0

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-02-06 19:53:46 +05:30
Sivin VargheseandGitHub 0d3b59fd9c feat: Refactor reports filters (#13443) 2026-02-06 18:22:30 +05:30
Shivam MishraandGitHub 04e747cc02 chore: temporarily disable ProcessStaleContactsJob (#13462)
We've been seeing orphan conversations (conversations whose contact has
been deleted) on high-frequency accounts. These orphans cause 500 errors
when the API attempts to render conversation data, since the jbuilder
partials expect a valid contact association.

The `ProcessStaleContactsJob` triggers `RemoveStaleContactsService`,
which uses `delete_all` to remove contacts. While the query only selects
contacts without conversations, `delete_all` bypasses ActiveRecord
callbacks and does not re-verify at deletion time. We suspect this
creates a window where a new conversation can be created for a contact
between query evaluation and the actual delete, leaving the conversation
orphaned.

**This is unverified — disabling the job is the experiment to confirm or
rule out this theory.**

This PR disables the job for a monitoring period. If orphan
conversations stop appearing, we'll have confirmation and can work on a
proper fix for the service. If they continue, we'll investigate other
deletion paths.

Stale contacts will accumulate while the job is disabled, but this is a
controlled tradeoff — they are inert records with no user-facing impact,
and can be cleaned up in bulk once we re-enable or fix the job.
2026-02-06 13:27:51 +05:30
053b7774dd fix: Render all account limit fields (#13435)
## Bug Explanation
- The Super Admin limits form renders inputs by iterating the keys of
`account.limits`.
- When `account.limits` was present, `AccountLimitsField#to_s` returned
only that hash (no defaults).
- On save, `SuperAdmin::AccountsController` compacts the limits hash,
removing blank keys.
- Result: if only one key (e.g., `agents`) was saved, the other keys
were missing from the hash and their fields disappeared on the next
render.

## Fix
- Always start from a defaults hash of all expected limit keys and merge
in any saved overrides.
- This keeps the UI stable and ensures all limit inputs remain visible
even when the stored hash is partial.
- Upgraded meta_request to `0.8.5` to stop a dev‑only `SystemStackError`
caused by JSON‑encoding ActiveRecord::Transaction in Rails 7.2. No
production behavior changes.

## Reproduction Steps
1. In Super Admin, edit an account and set only `agents` in the limits;
leave other limit fields blank and save.
2. Re-open the same account in Super Admin.
3. Observe that only `agents` is rendered and other limit fields are
missing.

## Testing
- Tested on UI

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-04 20:21:07 +05:30
Muhsin KelothandGitHub 8eaea7c72e feat: Add standalone outgoing messages count API endpoint (#13419)
This PR adds a new standalone `GET
/api/v2/accounts/:id/reports/outgoing_messages_count` endpoint that
returns outgoing message counts grouped by agent, team, inbox, or label.
2026-02-04 19:36:50 +05:30
Tanmay Deep SharmaandGitHub 7ade9061a8 feat: display total FAQ count in Related FAQs dialog (#13433)
## Description

Display the total count of generated FAQs in the Related FAQs dialog
title to give users immediate visibility into how many FAQs were
generated from a document.

## Type of change

Please delete options that are not relevant.

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

## Snapshots?

<img width="717" height="268" alt="Screenshot 2026-02-04 at 1 47 36 AM"
src="https://github.com/user-attachments/assets/c3e927ce-6d09-499d-8d02-8a44e0c353e2"
/>


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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Small UI-only change using existing store metadata; risk is limited to
incorrect/blank counts if `meta.totalCount` is missing or stale.
> 
> **Overview**
> Updates the `RelatedResponses` dialog to display the total related
response count in the title by reading
`captainResponses/getMeta.totalCount` (defaulting to 0) and appending it
as `(<count>)`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7cd67c9991faceeff33d33c319e324b1c6cf73f4. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-04 11:27:51 +05:30
Sojan Jose 9eb3ee44a8 Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit ef6ba8aabd.
2026-02-03 21:09:42 -08:00
Sojan JoseandGitHub ef6ba8aabd chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade
afterwards
 
 # Changelog
- `.circleci/config.yml` — align CI DB setup with GitHub Actions
(`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps.
- `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that
don't match existing spec style.
- `AGENTS.md` — document that specs should run without `.env` (rename
temporarily when present).
- `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin
`commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and
relax some gem pins.
- `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and
gem changes.
- `app/controllers/api/v1/accounts/integrations/linear_controller.rb` —
stringify params before passing to the Linear service to keep key types
stable.
- `app/controllers/super_admin/instance_statuses_controller.rb` — use
`MigrationContext` API for migration status in Rails 7.2.
- `app/models/installation_config.rb` — add commentary on YAML
serialization and future JSONB migration (no behavior change).
- `app/models/integrations/hook.rb` — ensure hook type is set on create
only and guard against missing app.
- `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation,
serialize OTP backup codes with JSON, and use Ruby `alias`.
- `app/services/crm/leadsquared/setup_service.rb` — stringify hook
settings keys before merge to keep JSON shape consistent.
- `app/services/macros/execution_service.rb` — remove macro-specific
assignee activity workaround; rely on standard assignment handlers.
- `config/application.rb` — load Rails 7.2 defaults.
- `config/storage.yml` — update Azure Active Storage service name to
`AzureBlob`.
- `db/migrate/20230515051424_update_article_image_keys.rb` — use
credentials `secret_key_base` with fallback to legacy secrets.
- `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native
extensions (Ruby 3.4 / psych).
- `lib/seeders/reports/message_creator.rb` — add parentheses for clarity
in range calculation.
- `package.json` — pin Vite version and bump `vite-plugin-ruby`.
- `pnpm-lock.yaml` — lockfile changes from JS dependency updates.
- `spec/builders/v2/report_builder_spec.rb` — disable transactional
fixtures; truncate tables per example via Rails `truncate_tables` so
after_commit callbacks run with clean isolation; keep builder spec
metadata minimal.
- `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable
transactional fixtures + truncate tables via Rails `truncate_tables`;
revert to real `resolved!`/`open!`/`resolved!` flow for multiple
resolution events; align date range to `Time.zone` to avoid offset gaps;
keep builder spec metadata minimal.
- `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert
`assignee_id` instead of activity message to avoid transaction-timing
flakes.
- `spec/services/telegram/incoming_message_service_spec.rb` — reference
the contact tied to the created conversation instead of
`Contact.all.first` to avoid order-dependent failures when other specs
leave data behind.
-
`spec/mailers/administrator_notifications/shared/smtp_config_shared.rb`
— use `with_modified_env` instead of stubbing mailer internals.
- `spec/services/account/sign_up_email_validation_service_spec.rb` —
compare error `class.name` for parallel/reload-safe assertions.
2026-02-03 14:29:26 -08:00
Vishnu NarayananandGitHub c884cdefde feat: add per-account daily rate limit for outbound emails (#13411)
Introduce a daily cap on non-channel outbound emails to prevent abuse.

Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## Summary
- Adds a Redis-based daily counter to rate limit outbound emails per
account, preventing email abuse
- Covers continuity emails (WebWidget/API), conversation transcripts,
and agent notifications
  - Email channel replies are excluded (paid feature, not abusable)
- Adds account suspension check in `ConversationReplyMailer` to block
already-queued emails for suspended accounts

  ## Limit Resolution Hierarchy
1. Per-account override (`account.limits['emails']`) — SuperAdmin
configurable
2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS`
InstallationConfig)
3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default:
100)
  4. Fallback (`ChatwootApp.max_limit` — effectively unlimited)

  ## Enforcement Points
  | Path | Where | Behavior |
  |------|-------|----------|
| WebWidget/API continuity |
`SendEmailNotificationService#should_send_email_notification?` |
Silently skipped |
| Widget transcript | `Widget::ConversationsController#transcript` |
Returns 429 |
| API transcript | `ConversationsController#transcript` | Returns 429 |
| Agent notifications | `Notification::EmailNotificationService#perform`
| Silently skipped |
  | Email channel replies | Not rate limited | Paid feature |
| Suspended accounts | `ConversationReplyMailer` | Blocked at mailer
level |
2026-02-03 02:06:51 +05:30
c77d935e38 fix: Subscribe app to WABA before overriding webhook callback URL (#13279)
#### Problem
Meta requires the app to be subscribed to the WABA before
`override_callback_uri` can be used. The current implementation tries to
use `override_callback_uri` directly, which fails with:

> Error 100: "Before override the current callback uri, your app must be
subscribed to receive messages for WhatsApp Business Account"

This causes embedded signup to fail silently, the inbox appears
connected but never receives messages.

  #### Solution

  Split `subscribe_waba_webhook` into two sequential API calls:

  ```ruby
  def subscribe_waba_webhook(waba_id, callback_url, verify_token)
    # Step 1: Subscribe app to WABA first (required before override)
    subscribe_app_to_waba(waba_id)

    # Step 2: Override callback URL for this specific WABA
    override_waba_callback(waba_id, callback_url, verify_token)
  end
```

#### References
  - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks
  - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-02 16:50:35 +05:30
Muhsin KelothandGitHub b686d14044 feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
PranavandGitHub e9e6de5690 fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410)
The specs break only in Circle CI, we have to figure out the root cause
for the same. At the moment, I have increased the parallelism to fix
this.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- Add API documentation for inbox, agent, and team summary report
endpoints
- These endpoints return conversation statistics grouped by
inbox/agent/team for a given date range

Endpoints documented:
GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation
stats grouped by inbox │
GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation
stats grouped by agent │
GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation
stats grouped by team │

Query parameters (all endpoints):
- since - Start timestamp (Unix)
- until - End timestamp (Unix)
- business_hours - Calculate metrics using business hours only

Response fields:
- id - Inbox/Agent/Team ID
- conversations_count - Total conversations in date range
- resolved_conversations_count - Resolved conversations in date range
- avg_resolution_time - Average resolution time (seconds)
- avg_first_response_time - Average first response time (seconds)
- avg_reply_time - Average reply time (seconds)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c chore: Update report documentation (#13408)
New API Documentation

GET
/api/v2/accounts/{account_id}/reports/first_response_time_distribution
  - Returns first response time distribution grouped by channel type
- Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h,
24h+
  - Parameters: since, until (Unix timestamps)

  GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix
  - Returns a matrix of conversation counts for inbox-label combinations
  - Parameters: since, until, inbox_ids[], label_ids[]

  Fixes

  - Removed unused business_hours boolean parameter from
  /api/v2/accounts/{account_id}/summary_reports/channel
- Updated ReDoc script from unstable @next to stable @2.1.5 version to
fix empty swagger page
2026-01-30 22:33:03 +04:00
5ec77aca64 feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.

Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).

- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance

Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
    "Channel::WebWidget": {
      "0-1h": 120,
      "1-4h": 85,
      "4-8h": 32,
      "8-24h": 12,
      "24h+": 3
    },
    "Channel::Email": {
      "0-1h": 12,
      "1-4h": 28,
      "4-8h": 45,
      "8-24h": 35,
      "24h+": 10
    },
    "Channel::FacebookPage": {
      "0-1h": 50,
      "1-4h": 30,
      "4-8h": 15,
      "8-24h": 8,
      "24h+": 2
    }
  }
```

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
Sivin VargheseandGitHub 85324c82fa fix: Formatting issue with reply preview content (#13399) 2026-01-30 16:35:32 +05:30
81307d5aea feat: search documentation tool for reply suggestions (#13340)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-30 16:18:33 +05:30
6f45af605c feat: Add inbox-label matrix report endpoint (#13394)
This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-29 13:32:59 -08:00
a32565d72b fix: velma connection limit (#13395)
## Description 
Make the $velma Redis connection pool size configurable via
`REDIS_VELMA_SIZE` environment variable (default: 5, matching current
behavior)
The $velma pool is used exclusively by Rack::Attack for rate limiting
and was the only Redis pool with a hardcoded size

## Fixes

Under high traffic, the hardcoded $velma pool (size: 5) causes
connection contention. Every HTTP request passes through Rack::Attack
middleware, which requires a $velma Redis connection. When
`WEB_CONCURRENCY=2` and `RAILS_MAX_THREADS=10` (20 concurrent threads),
the 4:1 thread-to-connection ratio causes threads to queue for up to 1
second (the pool timeout), resulting in intermittent request latency
spikes during traffic bursts.

The $alfred pool was already configurable via REDIS_ALFRED_SIZE — this
change brings $velma to parity.


## Type of change

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


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



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes only Redis connection pool sizing for Rack::Attack;
misconfiguration could cause rate-limiting Redis contention or extra
connections but no data/auth logic changes.
> 
> **Overview**
> Makes the `velma` Redis connection pool (used by Rack::Attack)
configurable via a new `REDIS_VELMA_SIZE` env var, replacing the
previously hardcoded pool size.
> 
> Documents `REDIS_VELMA_SIZE` in `.env.example` alongside the existing
`REDIS_ALFRED_SIZE` setting.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dcbc946f2e1d7356dc743178ca46cdf12cb25c78. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-29 20:53:41 +05:30
Muhsin KelothandGitHub 9238b4cc92 feat: Add the ability to configure WIDGET_TOKEN_EXPIRY (#13385)
This PR adds `WIDGET_TOKEN_EXPIRY` to the general config allowlist in the SuperAdmin app config page.
2026-01-29 09:57:01 +04:00
Muhsin KelothandGitHub acd0f1457e feat: Add TikTok social profile display support (#13384)
Fixes
https://linear.app/chatwoot/issue/PLA-77/store-tiktok-user-id-in-contact-attributes
Adds support for displaying TikTok profile links in the contact sidebar,
matching the behavior of other social channels (Instagram, Facebook,
Twitter, etc.).

<img width="400" height="600" alt="CleanShot 2026-01-28 at 15 48 14@2x"
src="https://github.com/user-attachments/assets/c861e6af-6a45-40ff-a4bf-7d46b8937691"
/>
2026-01-29 09:26:10 +04:00
77493c5d0f fix: captain assistant image comprehension (#13390)
# Pull Request Template

## Description

Fixes # (issue)

When we migrated to RubyLLM, images weren't being sent properly in
RubyLLM format to the model, so it did not understand images.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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

specs + local testing

Current behaviour on staging:
<img width="772" height="1012" alt="image"
src="https://github.com/user-attachments/assets/7b7d360f-dea4-48af-b20b-ee4c98a38a85"
/>

local testing with fix:
<img width="792" height="1216" alt="image"
src="https://github.com/user-attachments/assets/5ef82452-015e-4bda-a68f-884d00acb014"
/>


## Checklist:

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-28 16:07:13 -08:00
PranavandGitHub 0d9c0b2ed2 fix: Force account_id in the query (#13388)
### What

Forces `account_id` to be applied consistently in queries and message creation paths.

### Why

Some queries were missing `account_id`, leading to cross-account scans and slow performance in large datasets.

### Changes

* Added `account_id` to the relevant query columns.
* Ensured messages are always created within the correct account scope.
* Updated `created_at` handling where required for consistency.

### Impact

* Prevents cross-account queries.
* Improves query performance.
* Reduces risk of incorrect data access across accounts.

### Notes

No functional behavior change for end users. This is a performance and safety fix.
2026-01-28 14:51:24 -08:00
2a69b37958 chore: Update theme colors and add new Inter variable fonts (#13347)
# Pull Request Template

## Description

This PR includes the following updates:

1. Updated the design system color tokens by introducing new tokens for
surfaces, overlays, buttons, labels, and cards, along with refinements
to existing shades.
2. Refreshed both light and dark themes with adjusted background,
border, and solid colors.
3. Replaced static Inter font files with the Inter variable font
(including italic), supporting weights from 100–900.
4. Added custom font weights (420, 440, 460, 520) along with custom
typography classes to enable more fine-grained and consistent typography
control.


## Type of change

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


## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-28 14:36:04 -08:00
Vishnu NarayananandGitHub 0ca98bc84f feat: add lightweight /health endpoint (#13386)
The existing /api health check endpoint creates a new Redis connection
on every request and checks both Redis and Postgres availability. During
peak traffic, this creates unnecessary load and can cause cascading
failures when either service is slow - instances get marked unhealthy,
traffic shifts to remaining instances, which then also fail health
checks.

The new /health endpoint:
  - Returns immediately with 200 {"status":"woot"}
  - Skips all middleware and authentication
  - No Redis or Postgres dependency
- Suitable for health checks that only need to verify the web server is
responding
2026-01-29 00:24:01 +05:30
Muhsin KelothandGitHub 6cd1b37981 feat: Tiktok API version configurable via Super Admin (#13381)
Extracted hardcoded TikTok API version (`v1.3`) into a configurable
`TIKTOK_API_VERSION` setting, consistent with how Instagram and WhatsApp
handle API versions.

Fixes
https://linear.app/chatwoot/issue/CW-6408/tiktok-api-version-configurable-via-super-admin
2026-01-28 19:46:48 +04:00
Muhsin KelothandGitHub 3b612e2b20 chore: Add unsupported message for Tiktok (#13380)
This PR adds the unsupported messages for tiktok.
Fixes
https://linear.app/chatwoot/issue/CW-6407/add-support-for-unsupported-message
2026-01-28 19:34:11 +04:00
d166ae73bc feat: add cron job to remove orphan conversations (#13335)
## Description

This PR includes cron job to delete the orphans

## Type of change

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

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces a scheduled cleanup for conversations missing `contact` or
`inbox`.
> 
> - Adds `Internal::RemoveOrphanConversationsService` to batch-delete
orphan conversations (scoped by optional `account`, within a
configurable `days` window) with progress logging
> - New `Internal::RemoveOrphanConversationsJob` that invokes the
service; scheduled via `config/schedule.yml` to run every 12 hours on
`housekeeping` queue
> - Refactors rake task `chatwoot:ops:cleanup_orphan_conversations` to
use the service and report `total_deleted` after confirmation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
59a24715cc59f048d08db3f588cde6fa036f3166. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 19:25:20 +05:30
Muhsin KelothandGitHub aaeea6c9bf feat: Display story replies with attachment and context label (#13356)
Fixes https://github.com/chatwoot/chatwoot/issues/13354
Fixes
https://linear.app/chatwoot/issue/CW-6394/story-responses-are-not-being-shown-in-the-ui
When someone replies to your Instagram story, agents in Chatwoot only
see the reply text with no story image and no indication that it was a
story reply. This makes it impossible to understand what the customer is
responding to the message looks like a random text with no context. For
example, if a customer replies "Love this!" to your story, the agent
just sees "Love this!" with no way to know which story triggered the
conversation. This PR fixes the issue by storing the story attachment
and adding a context label.

<img width="1408" height="2052" alt="CleanShot 2026-01-27 at 19 19
38@2x"
src="https://github.com/user-attachments/assets/341afea9-98e3-4e47-b2fa-ef77fe32851f"
/>
2026-01-28 16:47:04 +04:00
b870a48734 perf: limit the number of notifications per user to 300 (#13234)
## Linear issue


https://linear.app/chatwoot/issue/CW-6289/limit-the-number-of-notifications-per-user-to-300

## Description

Limits the number of notifications per user to 300 by introducing an
async trim job that runs after each notification creation. This prevents
unbounded notification growth that was causing DB CPU spikes.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Added unit tests for TrimUserNotificationsJob

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Implements a dedicated purge job to control notification volume and
scheduling.
> 
> - Introduces `Notification::RemoveOldNotificationJob` (queue:
`purgable`) to delete notifications older than 1 month and trim each
user to the 300 most recent (deterministic by `created_at DESC, id
DESC`)
> - Adds daily cron (`remove_old_notification_job` at 22:30 UTC, queue
`purgable`) in `config/schedule.yml`
> - Removes ad-hoc triggering of the purge from
`TriggerScheduledItemsJob`
> - Adds/updates specs covering enqueue queue, old-notification
deletion, per-user trimming, and combined behavior
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9ea2b48e36df96cd15d4119d1dd7dcf5250695de. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-28 17:35:13 +05:30
68f0da7351 fix: Attachments download authentication issue in Tiktok (#13151)
Fixes
https://linear.app/chatwoot/issue/CW-6357/ensure-authentication-when-fetching-attachments
Update the attachment download method to include the access token in the
request headers, ensuring proper authentication when fetching
attachments.

https://business-api.tiktok.com/portal/docs?id=1832184455450626

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 11:50:14 +04:00
40c622ed95 fix: Tiktok nil conversation handling in ReadStatusService (#13152)
Fixes
https://linear.app/chatwoot/issue/CW-6358/handling-of-nil-conversation-in-the-readstatusservice
Improve handling of nil conversation in the `ReadStatusService` to
prevent potential errors. Ensure that the conversation is checked before
performing updates to message status. This change fixes the below error.



```
NoMethodError: undefined method 'conversations' for nil (NoMethodError)

    channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
                                                                        ^^^^^^^^^^^^^^
    from app/services/tiktok/messaging_helpers.rb:29:in 'Tiktok::MessagingHelpers#find_conversation'
    from app/services/tiktok/read_status_service.rb:13:in 'Tiktok::ReadStatusService#conversation'
    from app/services/tiktok/read_status_service.rb:9:in 'Tiktok::ReadStatusService#perform'
    from app/jobs/webhooks/tiktok_events_job.rb:67:in 'Webhooks::TiktokEventsJob#im_mark_read_msg'
    from app/jobs/webhooks/tiktok_events_job.rb:31:in 'Webhooks::TiktokEventsJob#process_event'
    from app/jobs/webhooks/tiktok_events_job.rb:15:in 'block in Webhooks::TiktokEventsJob#perform'
    from app/jobs/mutex_application_job.rb:23:in 'MutexApplicationJob#with_lock'
    from app/jobs/webhooks/tiktok_events_job.rb:14:in 'Webhooks::TiktokEventsJob#perform'
    from activejob (7.1.5.2) lib/active_job/execution.rb:68:in 'block in ActiveJob::Execution#_perform_job'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:121:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from i18n (1.14.7) lib/i18n.rb:353:in 'I18n::Base#with_locale'
    from activejob (7.1.5.2) lib/active_job/translation.rb:9:in 'block (2 levels) in <module:Translation>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/core_ext/time/zones.rb:65:in 'Time.use_zone'
    from activejob (7.1.5.2) lib/active_job/timezones.rb:9:in 'block (2 levels) in <module:Timezones>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:141:in 'ActiveSupport::Callbacks#run_callbacks'
    from activejob (7.1.5.2) lib/active_job/execution.rb:67:in 'ActiveJob::Execution#_perform_job
```

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 10:41:19 +04:00
PranavandGitHub 7cddba2b08 feat: Add infinite scroll to contacts search page (#13376)
## Summary
- Add `has_more` to contacts search API response to enable infinite
scroll without expensive count queries
- Set `count` to the number of items in the current page instead of
total count
- Implement "Load more" button for contacts search results
- Keep existing contacts visible while loading additional pages

## Changes

### Backend
- Add `fetch_contacts_with_has_more` method that fetches N+1 records to
determine if more pages exist
- Return `has_more` in search endpoint meta response
- Set `count` to current page size instead of total count

### Frontend
- Add `APPEND_CONTACTS` mutation for appending contacts without clearing
existing ones
- Update search action to support `append` parameter
- Add `ContactsLoadMore` component with loading state
- Update `ContactsListLayout` to support infinite scroll mode
- Update `ContactsIndex` to use infinite scroll for search view
2026-01-27 18:55:19 -08:00
04b2901e1f feat: Conversation workflows(EE) (#13040)
We are expanding Chatwoot’s automation capabilities by
introducing **Conversation Workflows**, a dedicated section in settings
where teams can configure rules that govern how conversations are closed
and what information agents must fill before resolving. This feature
helps teams enforce data consistency, collect structured resolution
information, and ensure downstream reporting is accurate.

Instead of having auto‑resolution buried inside Account Settings, we
introduced a new sidebar item:
- Auto‑resolve conversations (existing behaviour)
- Required attributes on resolution (new)

This groups all conversation‑closing logic into a single place.

#### Required Attributes on Resolve

Admins can now pick which custom conversation attributes must be filled
before an agent can resolve a conversation.

**How it works**

- Admin selects one or more attributes from the list of existing
conversation level custom attributes.
- These selected attributes become mandatory during resolution.
- List all the attributes configured via Required Attributes (Text,
Number, Link, Date, List, Checkbox)
- When an agent clicks Resolve Conversation:
If attributes already have values → the conversation resolves normally.
If attributes are missing → a modal appears prompting the agent to fill
them.

<img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42
23@2x"
src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373"
/>


#### Custom Attributes Integration

On the Custom Attributes page, we will surfaced indicators showing how
each attribute is being used.

Each attribute will show badges such as:

- Resolution → used in the required‑on‑resolve workflow

- Pre‑chat form → already existing

<img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43
42@2x"
src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
/>


#### Admin Flow

- Navigate to Settings → Conversation Workflows.
- Under Required attributes on resolve, click Add Required Attribute.
- Pick from the dropdown list of conversation attributes.
- Save changes.

Agents will now be prompted automatically whenever they resolve.

<img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x"
src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319"
/>



#### NOTES
- The Required Attributes on Resolve modal should only appear when
values are missing.
- Required attributes must block the resolution action until satisfied.
- Bulk‑resolve actions should follow the same rules — any conversation
missing attributes cannot be bulk‑resolved, rest will be resolved, show
a notification that the resolution cannot be done.
- API resolution does not respect the attributes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-27 11:36:20 +04:00
TheDanniCraftandGitHub 885b041a83 fix: Update help center sitemap XML structure (#13357)
# Pull Request Template

## Description
The Help Center sitemap endpoint (`/hc/:portal_slug/sitemap.xml`)
previously rendered a `<sitemapindex>` element while embedding article
URLs directly, which does not align with the sitemap specification.

This change fixes the structure by:
- Replacing `<sitemapindex>` with `<urlset>`
- Adding the required sitemap XML namespace
- Rendering each published article as a `<url>` entry with `<loc>` and
`<lastmod>`

This ensures the endpoint outputs a valid, self-contained sitemap
document.

Fixes #13334

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?
- Updated the existing `portals_controller_spec.rb`
- Adjusted assertions to validate a `<urlset>` root element and the
sitemap XML namespace
- Verified that the sitemap returns only published article URLs
- Ran the updated RSpec controller specs locally


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-01-26 18:08:20 -08:00
PranavandGitHub ad2329c237 perf(conversations): throttle agent_last_seen_at updates to reduce DB load (#13355)
High-traffic accounts generate excessive database writes due to agents
frequently switching between conversations. The update_last_seen
endpoint was being called every time an agent loaded a conversation,
resulting in unnecessary updates to agent_last_seen_at and
assignee_last_seen_at even when there were no new messages to mark as
read.

#### Solution
Implemented throttling for the update_last_seen endpoint:

**Unread messages present:**
- Updates immediately without throttling to maintain accurate
read/unread state
- Uses assignee_unread_messages for assignees, unread_messages for other
agents

**No unread messages:**
- Throttles updates to once per hour per conversation
- Checks if agent_last_seen_at is older than 1 hour before updating
- For assignees, checks both agent_last_seen_at AND
assignee_last_seen_at - updates if either timestamp is old
- Skips DB write if all relevant timestamps were updated within the last
hour

- Consolidated two separate update_column calls into a single
update_columns call to reduce DB queries
2026-01-23 22:23:41 -08:00
PranavandGitHub 747d451387 chore: Improve signup flow, reduce the number of inputs (#13350)
- Improved design for the Chatwoot sign up page
2026-01-22 18:47:42 -08:00
Shivam MishraandGitHub 8eb6fd1bff feat: track copilot events (#13342) 2026-01-22 18:38:04 +05:30
Tanmay Deep SharmaandGitHub 75f75ce786 fix: sanitize integer fields to prevent Elasticsearch mapping errors (#13276)
## Linear task:

https://linear.app/chatwoot/issue/CW-6318/searchkickimporterror-type-=-mapper-parsing-exception-reason-=-failed

## Description

Fixes Elasticsearch `mapper_parsing_exception` errors that occur when
`campaign_id` contain non-numeric string values

## Type of change

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

## How Has This Been Tested?

- Unit tests
- use a local OpenSearch 3.4.0 cluster to verify actual indexing
behavior.


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


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Removes `campaign_id` from the message search index payload to
simplify `additional_attributes`, keeping only `automation_rule_id`.
> 
> - `Messages::SearchDataPresenter#additional_attributes_data` now
returns only `automation_rule_id`
> - Specs updated to stop asserting `campaign_id` and continue
validating `automation_rule_id` and email subject handling
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5a9c8eb794a044e3f258b644f67a6731de9e904c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-01-22 18:29:49 +05:30
964d2f8544 perf: use account.contacts directly in search to reduce DB load (#12956)
- Use resolved contacts instead of accounts.contacts for search

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2026-01-22 17:59:38 +05:30
Muhsin KelothandGitHub 1f5fdd7199 fix: Add Portuguese (Brazil) to CSAT template language options (#13343)
Added Portuguese (Brazil) (`pt_BR`) to the CSAT template language
dropdown
2026-01-22 15:59:24 +04:00
PranavandGitHub 9e97cc0cdd fix(whatsapp): Preserve ordered list numbering in messages (#13339)
- Fix ordered lists being sent as unordered lists in WhatsApp
integrations
- The WhatsApp markdown renderer was converting all lists to bullet
points (-), ignoring numbered list formatting
- Added ordered list support by tracking list_type and list_item_number
from CommonMarker AST metadata

Before:
Input: "1. First\n2. Second\n3. Third"
Output: "- First\n- Second\n- Third"

After:

Input: "1. First\n2. Second\n3. Third"
Output: "1. First\n2. Second\n3. Third"
2026-01-22 14:14:40 +04:00
Aakash BakhleandGitHub 70d09fcc66 fix: make llm aware about signatures in editor replies (#13332)
Fixes signatures being generated on top of existing signature in draft
for improve, tone change and grammar
2026-01-21 15:52:41 +05:30
Shivam MishraandGitHub cc5ec833dc feat: check if label suggestion is enabled in hooks (#13331) 2026-01-21 15:11:41 +05:30
Vinay KeerthiandGitHub f84e95ed6c fix: use safe DOM manipulation for article heading permalinks (#13239) 2026-01-21 13:44:15 +05:30
6a482926b4 feat: new Captain Editor (#13235)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-01-21 13:39:07 +05:30
Sojan Jose c77c9c9d8a Merge branch 'release/4.10.1' into develop 2026-01-20 08:44:21 -08:00
Sojan Jose 1345f67966 Merge branch 'release/4.10.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-20 08:44:14 -08:00
Sojan Jose ecd4892a23 Bump version to 4.10.1 2026-01-20 08:43:11 -08:00
Muhsin KelothandGitHub 457430e8d9 fix: Remove phone_number_id param from WhatsApp media retrieval for incoming messages (#13319)
Fixes https://github.com/chatwoot/chatwoot/issues/13317
Fixes an issue where WhatsApp attachment messages (images, audio, video,
documents) were failing to download. Messages were being created but
without attachments.

The `phone_number_id` parameter was being passed to the `GET
/<MEDIA_ID>` endpoint when downloading incoming media. According to
Meta's documentation:

> "Note that `phone_number_id` is optional. If included, the request
will only be processed if the business phone number ID included in the
query matches the ID of the business
  phone number **that the media was uploaded on**."

For incoming messages, media is uploaded by the customer, not by the
business phone number. Passing the business's `phone_number_id` causes
validation to fail with error: `Param phone_number_id is not a valid
whatsapp business phone number id ID`

This PR removes the `phone_number_id` parameter from the media URL
request for incoming messages.
2026-01-20 20:32:23 +04:00
Shivam MishraandGitHub e13e3c873a feat: add report download task (#13250) 2026-01-19 18:31:52 +05:30
Shivam MishraandGitHub 0346e9a2c7 fix: captain inbox modal shows wrong assistant data (#13302) 2026-01-19 18:31:46 +05:30
Muhsin KelothandGitHub 7e4d93f649 fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278)
Fixes https://github.com/chatwoot/chatwoot/issues/13097

### Problem
The PR #12176 removed the `before_save :setup_webhooks` callback to fix
a race condition where Meta's webhook verification request arrived
before the channel was saved to the database. This change broke manual
WhatsApp Cloud channel setup. While embedded signup explicitly calls
`channel.setup_webhooks` in `EmbeddedSignupService`, manual setup had no
equivalent call - meaning the `subscribed_apps` endpoint was never
invoked and Meta never sent webhook events to Chatwoot.


### Solution
Added an `after_commit` callback that triggers webhook setup for manual
WhatsApp Cloud channels
2026-01-19 14:12:36 +04:00
b2ffad1998 fix: Validate status and priority params in search conversations tool (#13295)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:08:32 +05:30
Sojan Jose b2eca91c79 Merge branch 'release/4.10.0' into develop 2026-01-15 22:20:33 -08:00
Sojan Jose 0f914fa2ab Merge branch 'release/4.10.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-15 22:20:22 -08:00
Sojan Jose aee0740bcc Bump version to 4.10.0 2026-01-15 22:19:33 -08:00
96b5780ea7 fix: Respect survey label rules for WhatsApp CSAT template (#13285)
Ensure CSAT survey label rules are evaluated once in CsatSurveyService
before any channel-specific sending (including WhatsApp/Twilio
templates), remove the duplicated rule check from the template builder,
and cover the blocking-label scenario in service specs while simplifying
the template specs accordingly.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:16:00 -08:00
d451615811 fix: prevent NoMethodError in mute helpers when contact is nil (#13277)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-4569/nomethoderror-undefined-method-blocked-for-nil-nomethoderror

## Description
Fixes NoMethodError in ConversationMuteHelpers that occurs during
contact deletion race condition.
When a contact is deleted, there's a brief window (~50-150ms) where
contact_id becomes nil but conversations still exist. If ResolutionJob
runs during this window, the muted? method crashes trying to call
blocked? on nil.Fixes # (issue)

## Type of change

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

## How Has This Been Tested?

- Created orphaned conversations (contact_id = nil)
- Called muted?, mute!, unmute! - all return gracefully
- Verified async deletion still works correctly

## 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: Sojan Jose <sojan@pepalo.com>
2026-01-15 22:00:09 -08:00
7d68c25c97 chore: upgrade node to v24.13 (#13291)
- upgrade node to v24.13

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-15 20:28:13 -08:00
3e560cb4cc chore: Remove year in review from sidebar UI (#13292)
Remove the Year In review from the UI

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:57:01 -08:00
PranavandGitHub a8b302d4cd feat(ee): Review Notes for CSAT Reports (#13289)
CSAT scores are helpful, but on their own they rarely tell the full
story. A drop in rating can come from delayed timelines, unclear
expectations, or simple misunderstandings, even when the issue itself
was handled correctly.

Review Notes for CSAT let admins/report manager roles add internal-only
context next to each CSAT response. This makes it easier to interpret
scores properly and focus on patterns and root causes, not just numbers.


<img width="2170" height="1680" alt="image"
src="https://github.com/user-attachments/assets/56df7fab-d0a7-4a94-95b9-e4c459ad33d5"
/>


### Why this matters

* Capture the real context behind individual CSAT ratings
* Clarify whether a low score points to a genuine service issue or a
process gap
* Spot recurring themes across conversations and teams
* Make CSAT reviews more useful for leadership reviews and
retrospectives

### How Review Notes work

**View CSAT responses**
Open the CSAT report to see overall metrics, rating distribution, and
individual responses.

**Add a Review Note**
For any CSAT entry, managers can add a Review Note directly below the
customer’s feedback.

**Document internal insights**
Use Review Notes to capture things like:

* Why a score was lower or higher than expected
* Patterns you are seeing across similar cases
* Observations around communication, timelines, or customer expectations

Review Notes are visible only to administrators and people with report
access only. We may expand visibility to agents in the future based on
feedback. However, customers never see them.

Each note clearly shows who added it and when, making it easy to review
context and changes over time.
2026-01-15 19:53:57 -08:00
Gabriel JablonskiandGitHub da42a4e4a1 ci: fixed bundler version to avoid bundler 4.0.4 incompatibility (#13290)
### Problem

Docker builds started failing with a misleading dependency resolution
error:
```
Could not find compatible versions
Because every version of devise-secure_password depends on railties >= 5.0.0, < 8.0.0
...
```

### Root Cause

[Bundler
4.0.4](https://github.com/ruby/rubygems/releases/tag/bundler-v4.0.4) was
released on January 14, 2026. The Dockerfile used `gem install bundler`
without version pinning, which installed the latest (4.0.4) instead of
the version specified in `Gemfile.lock` (2.5.16).

### Fix

- Pin Bundler installation to match `Gemfile.lock`: `gem install bundler
-v "$BUNDLER_VERSION"`
- Update `BUNDLER_VERSION` from 2.5.11 to 2.5.16

### ⚠️ Note

Fix found by Claude while checking why the deploy was failing, and have
not yet checked for alternatives which might be more appropriate, so
further investigation might be required.
2026-01-15 19:51:16 -08:00
Muhsin KelothandGitHub 8eb0558b0a fix: Case-insensitive language matching for WhatsApp template messages (#13269)
Fixes https://github.com/chatwoot/chatwoot/issues/13257
When sending WhatsApp template messages via API with `processed_params`,
users receiving error `(#132000) Number of parameters does not match the
expected number of params` from WhatsApp. The find template method
performed case-sensitive string comparison on language codes. If a user
sent `language: "ES"` but the template was stored as `language: "es"`,
the template wouldn't be found, resulting in empty `components: []`
being sent to WhatsApp.
2026-01-14 17:33:02 +04:00
Tanmay Deep SharmaandGitHub ee7187d2ed fix: prevent deserialization error on deletion (#13264) 2026-01-14 18:00:12 +05:30
4e0b091ef8 fix: Prevent unsupported file types on clipboard paste (#13182)
# Pull Request Template

## Description

This PR adds file type validation for clipboard-pasted attachments and
prevents unsupported file types from being attached across channels.

https://developers.chatwoot.com/self-hosted/supported-features#outgoing-attachments-supported-file-types

Fixes
https://linear.app/chatwoot/issue/CW-6233/bug-unsupported-file-types-allowed-via-clipboard-paste

## 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/882c335be4894d86b9e149d9f7560e72

**After**
https://www.loom.com/share/90ad9605fc4446afb94a5b8bbe48f7db


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-14 13:07:46 +04:00
Sivin VargheseandGitHub daaa18b18a chore: Use widget color for chat input focus state (#13214) 2026-01-14 14:33:56 +05:30
Aakash BakhleandGitHub bddf06907b fix: double counting in langfuse instrumentation (#13202) 2026-01-13 18:52:38 +05:30
1a220b2982 chore: Improve compose new conversation form (#13176)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-13 18:52:10 +05:30
c483034a07 feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)
Fixes
https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 16:32:02 +04:00
Shivam MishraandGitHub 7b51939f07 fix: country_code should be checked against the contact (#13186) 2026-01-13 14:47:27 +05:30
821a5b85c2 feat: Add conversations summary CSV export (#13110)
# Pull Request Template

## Description

This PR adds support for exporting conversation summary reports as CSV.
Previously, the Conversations report incorrectly showed an option to
download agent reports; this has now been fixed to export
conversation-level data instead.

Fixes
https://linear.app/chatwoot/issue/CW-6176/conversation-reports-export-button-exports-agent-reports-instead

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

### Screenshot
<img width="1859" height="1154" alt="image"
src="https://github.com/user-attachments/assets/419d26f4-fda9-4782-aea6-55ffad0c37ab"
/>



## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-13 12:30:26 +04:00
PranavandGitHub 0917e1a646 feat: Add an API to support querying metrics by ChannelType (#13255)
This API gives you how many conversations exist per channel, broken down
by status in a given time period. The max time period is capped to 6
months for now.

**Input Params:**
- **since:** Unix timestamp (seconds) - start of date range
- **until:** Unix timestamp (seconds) - end of date range


**Response Payload:**

```json
{
  "Channel::Sms": {
    "resolved": 85,
    "snoozed": 10,
    "open": 5,
    "pending": 5,
    "total": 100
  },
  "Channel::Email": {
    "resolved": 72,
    "snoozed": 15,
    "open": 13,
    "pending": 13,
    "total": 100
  },
  "Channel::WebWidget": {
    "resolved": 90,
    "snoozed": 7,
    "open": 3,
    "pending": 3,
    "total": 100
  }
}
```

**Definitons:**
resolved = Number of conversations created within the selected time
period that are currently marked as resolved.
snoozed = Number of conversations created within the selected time
period that are currently marked as snoozed.
pending = Number of conversations created within the selected time
period that are currently marked as pending.
open = Number of conversations created within the selected time period
that are currently open.
total = Total number of conversations created within the selected time
period, across all statuses.
2026-01-12 23:18:47 -08:00
Sojan Jose 9407cc2ad5 Merge branch 'hotfix/4.9.2' into develop 2026-01-12 09:15:23 -08:00
Sojan Jose 59663dd558 Merge branch 'hotfix/4.9.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2026-01-12 09:15:17 -08:00
Sojan Jose ff68c3a74f Bump version to 4.9.2 2026-01-12 09:14:25 -08:00
Shivam MishraandGitHub 58cec84b93 feat: sanitize html before assiging it to tempDiv (#13252) 2026-01-12 22:41:37 +05:30
34b42a1ce1 feat: add global config for captain settings (#13141)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-01-12 19:54:19 +05:30
Vishnu NarayananandGitHub ab83a663f0 chore: upgrade node to 24.x LTS (#13004)
Upgrade node to `24.12` LTS

FIxes https://github.com/chatwoot/chatwoot/issues/12546
Fixes https://linear.app/chatwoot/issue/CW-5707/nodejs-23-reached-eol
2026-01-12 18:10:23 +05:30
Shivam MishraandGitHub b099d3a1eb fix: PDF errors not loading in CI (#13236) 2026-01-12 15:22:15 +05:30
d526cf283d fix: pass serialized data in notification.deleted event to avoid Deserialisation (#13061)
https://one.newrelic.com/alerts/issue?account=3437125&duration=259200000&state=d088e9b7-d0ce-3fcf-fda5-145df8b9cb2a


## Description
Pass serialized data instead of ActiveRecord object in
dispatch_destroy_event to prevent ActiveJob::DeserializationError when
the notification is already deleted.

This error occurs frequently because RemoveDuplicateNotificationJob
deletes notifications, and by the time the async EventDispatcherJob
runs, the record no longer exists.

## Type of change

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

## How Has This Been Tested?



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


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Avoids ActiveJob deserialization failures by sending serialized data
for notification deletion and updating the listener accordingly.
> 
> - `Notification#dispatch_destroy_event` now dispatches
`NOTIFICATION_DELETED` with serialized `notification_data` (`id`,
`user_id`, `account_id`) instead of the AR object
> - `ActionCableListener#notification_deleted` reads
`notification_data`, finds `User`/`Account`, computes
`unread_count`/`count` via `NotificationFinder`, and broadcasts using
the user’s pubsub token
> - Specs updated to pass `notification_data` and assert payload
(including `unread_count`/`count`)
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e2ffbe765b148fdfd2cd2e031c657c36e423c1f5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-12 13:15:40 +05:30
Sivin VargheseandGitHub a2e348df06 fix: Backslash issue with -- and improve autolink handling (#13208) 2026-01-12 11:22:44 +05:30
PranavandGitHub f5957e7970 fix: Reset sidebar to show expanded list when refreshing the page (#13229)
Previously, the sidebar remembered which section was expanded using
session storage. This caused a confusing experience where the sidebar
would collapse on page refresh.

With this update, the session storage dependency is removed, and the
sidebar would expand based on the current active page, which gives a
cleaner UX.
2026-01-11 00:31:17 -08:00
Chatwoot BotandGitHub 8b230c6920 chore: Update translations (#13109) 2026-01-09 16:11:44 -08:00
c7da5b4cde chore(docs): Add contact merge endpoint to swagger documentation (#13172)
## Summary

This PR adds API documentation for the contact merge endpoint:

`POST /api/v1/accounts/{account_id}/actions/contact_merge`

This endpoint allows merging two contacts into one. The base contact
survives and receives all data from the mergee contact, which is then
deleted.

## Changes

- Added `swagger/paths/application/contacts/merge.yml` with complete
endpoint documentation
- Updated `swagger/paths/index.yml` to include the new endpoint

## Related Issues

Closes chatwoot/docs#243

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:30:46 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
b0863ab1cd chore(deps): bump httparty from 0.21.0 to 0.24.0 (#13199)
Bumps [httparty](https://github.com/jnunemaker/httparty) from 0.21.0 to
0.24.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jnunemaker/httparty/releases">httparty's
releases</a>.</em></p>
<blockquote>
<h2>v0.24.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Force binary encoding throughout by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/823">jnunemaker/httparty#823</a></li>
<li>set Content-Type for Hash body in requests by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/828">jnunemaker/httparty#828</a></li>
<li>feat: stream multipart file uploads to reduce memory usage by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/829">jnunemaker/httparty#829</a></li>
<li>fix: prevent SSRF via absolute URL bypassing base_uri by <a
href="https://github.com/jnunemaker"><code>@​jnunemaker</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/830">jnunemaker/httparty#830</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.2...v0.24.0">https://github.com/jnunemaker/httparty/compare/v0.23.2...v0.24.0</a></p>
<h2>0.23.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Add changelog_uri metadata to gemspec by <a
href="https://github.com/baraidrissa"><code>@​baraidrissa</code></a> in
<a
href="https://redirect.github.com/jnunemaker/httparty/pull/817">jnunemaker/httparty#817</a></li>
<li>Fix multipart with files in binary mode and fields including
non-ASCII characters by <a
href="https://github.com/rdimartino"><code>@​rdimartino</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/822">jnunemaker/httparty#822</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/baraidrissa"><code>@​baraidrissa</code></a>
made their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/817">jnunemaker/httparty#817</a></li>
<li><a
href="https://github.com/rdimartino"><code>@​rdimartino</code></a> made
their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/822">jnunemaker/httparty#822</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.1...v0.23.2">https://github.com/jnunemaker/httparty/compare/v0.23.1...v0.23.2</a></p>
<h2>v0.23.1</h2>
<ul>
<li>Add foul option to class level <a
href="https://github.com/jnunemaker/httparty/commit/d2683879c902b278a0776620dd7510c99a9db670">https://github.com/jnunemaker/httparty/commit/d2683879c902b278a0776620dd7510c99a9db670</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.23.0...v0.23.1">https://github.com/jnunemaker/httparty/compare/v0.23.0...v0.23.1</a></p>
<h2>v0.23.0</h2>
<h2>What's Changed</h2>
<ul>
<li>new: foul mode to rescue all common network errors: <a
href="https://github.com/jnunemaker/httparty/blob/891a4a8093afd4cacecab2719223e3170d07f1c0/examples/party_foul_mode.rb">https://github.com/jnunemaker/httparty/blob/891a4a8093afd4cacecab2719223e3170d07f1c0/examples/party_foul_mode.rb</a></li>
<li>docs: replace master branch to main for better view by <a
href="https://github.com/bestony"><code>@​bestony</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/803">jnunemaker/httparty#803</a></li>
<li>Update README.md by <a
href="https://github.com/tradesmanhelix"><code>@​tradesmanhelix</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/811">jnunemaker/httparty#811</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/ashishra0"><code>@​ashishra0</code></a>
made their first contribution with foul mode</li>
<li><a href="https://github.com/bestony"><code>@​bestony</code></a> made
their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/803">jnunemaker/httparty#803</a></li>
<li><a
href="https://github.com/tradesmanhelix"><code>@​tradesmanhelix</code></a>
made their first contribution in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/811">jnunemaker/httparty#811</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jnunemaker/httparty/compare/v0.22.0...v0.23.0">https://github.com/jnunemaker/httparty/compare/v0.22.0...v0.23.0</a></p>
<h2>v0.22.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix typo in example name by <a
href="https://github.com/xymbol"><code>@​xymbol</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/780">jnunemaker/httparty#780</a></li>
<li>Extract request building method by <a
href="https://github.com/aliismayilov"><code>@​aliismayilov</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/786">jnunemaker/httparty#786</a></li>
<li>CI: Tell dependabot to update GH Actions by <a
href="https://github.com/olleolleolle"><code>@​olleolleolle</code></a>
in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/791">jnunemaker/httparty#791</a></li>
<li>Add CSV gem as a dependency for Ruby 3.4 by <a
href="https://github.com/ngan"><code>@​ngan</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/796">jnunemaker/httparty#796</a></li>
<li>Clear body when redirecting to a GET by <a
href="https://github.com/rhett-inbox"><code>@​rhett-inbox</code></a> in
<a
href="https://redirect.github.com/jnunemaker/httparty/pull/783">jnunemaker/httparty#783</a></li>
<li>CI against Ruby 3.3 by <a
href="https://github.com/y-yagi"><code>@​y-yagi</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/798">jnunemaker/httparty#798</a></li>
<li>Bump actions/checkout from 3 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/jnunemaker/httparty/pull/792">jnunemaker/httparty#792</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jnunemaker/httparty/blob/main/Changelog.md">httparty's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable <a
href="https://github.com/jnunemaker/httparty/releases">changes since
0.22 are documented in GitHub Releases</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/jnunemaker/httparty/commit/55ec76e8d1df7903eab3f7c2367991400d3cf65e"><code>55ec76e</code></a>
Release 0.24.0</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/ddfbc8ddfca03d4f4026b01763ee906071ca558b"><code>ddfbc8d</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/830">#830</a>
from jnunemaker/fix-ssrf-base-uri-bypass</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/0529bcd6309c9fd9bfdd50ae211843b10054c240"><code>0529bcd</code></a>
fix: prevent SSRF via absolute URL bypassing base_uri
(GHSA-hm5p-x4rq-38w4)</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/05f38fd35d8088b9770513c2eaecce671f0940ec"><code>05f38fd</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/829">#829</a>
from jnunemaker/memory</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/8901c238c00d0aca8920271314c4c5d7dd2701fb"><code>8901c23</code></a>
feat: stream multipart file uploads to reduce memory usage</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/091bd6aa909e38822b72f8ce2383385cf8eeb302"><code>091bd6a</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/828">#828</a>
from jnunemaker/issue-826</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/59c0ac5f3d906fb6be2133c1b89d75329755af8f"><code>59c0ac5</code></a>
feat: set Content-Type for Hash body in requests</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/5c8b45e6297d181d99a56f5297dade3e358cc6f9"><code>5c8b45e</code></a>
Merge pull request <a
href="https://redirect.github.com/jnunemaker/httparty/issues/823">#823</a>
from jnunemaker/mixed-encodings</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/6419cb307dd435572963e4ab40cd96b41389efcf"><code>6419cb3</code></a>
Force binary encoding throughout</li>
<li><a
href="https://github.com/jnunemaker/httparty/commit/c74571f7925c8e142d02c2b7d6ebeedf923b1dd1"><code>c74571f</code></a>
Release 0.23.2</li>
<li>Additional commits viewable in <a
href="https://github.com/jnunemaker/httparty/compare/v0.21.0...v0.24.0">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot 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>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:21:16 -08:00
005a22fd69 feat: Add sorting by contacts count to companies list (#13012)
## Description

Adds the ability to sort companies by the number of contacts they have
(contacts_count) in ascending or descending order. This is part of the
Chatwoot 5.0 release requirements for the companies feature.

The implementation uses a scope-based approach consistent with other
sorting implementations in the codebase (e.g., contacts sorting by
last_activity_at).

## Type of change

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

## Available Sorting Options

After this change, the Companies API supports the following sorting
options:

| Sort Field | Type | Ascending | Descending |
|------------|------|-----------|------------|
| `name` | string | `?sort=name` | `?sort=-name` |
| `domain` | string | `?sort=domain` | `?sort=-domain` |
| `created_at` | datetime | `?sort=created_at` | `?sort=-created_at` |
| `contacts_count` | integer (scope) | `?sort=contacts_count` |
`?sort=-contacts_count` |

**Note:** Prefix with `-` for descending order. Companies with NULL
contacts_count will appear last (NULLS LAST).

## CURL Examples

**Sort by contacts count (ascending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=contacts_count' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by contacts count (descending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-contacts_count' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by name (ascending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=name' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**Sort by created_at (descending):**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-created_at' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

**With pagination:**
```bash
curl -X GET 'https://app.chatwoot.com/api/v1/accounts/{account_id}/companies?sort=-contacts_count&page=2' \
  -H 'api_access_token: YOUR_API_TOKEN'
```

## How Has This Been Tested?

- Added RSpec tests for both ascending and descending sort
- All 24 existing specs pass
- Manually tested the sorting functionality with test data

**Test configuration:**
- Ruby 3.4.4
- Rails 7.1.5.2
- PostgreSQL (test database)

**To reproduce:**
1. Run `bundle exec rspec
spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`
2. All tests should pass (24 examples, 0 failures)

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

## Technical Details

**Backend changes:**
- Controller: Added `sort_on :contacts_count` with scope-based sorting
- Model: Added `order_on_contacts_count` scope using
`Arel::Nodes::SqlLiteral` and `sanitize_sql_for_order` with `NULLS LAST`
for consistent NULL handling
- Specs: Added 2 new tests for ascending/descending sort validation

**Files changed:**
- `enterprise/app/controllers/api/v1/accounts/companies_controller.rb`
- `enterprise/app/models/company.rb`
-
`spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`

**Note:** This PR only includes the backend implementation. Frontend
changes (sort menu UI + i18n) will follow in a separate commit.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:20:08 -08:00
ba3eb787e7 fix: Avoid double notification email after importing contacts (#13150)
Eliminate the second email notification sent to the account admins after processing the contacts import.

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-09 15:11:19 -08:00
PranavandGitHub 837026c146 feat: Use amplitude for Cloud Analytics (#13217)
Migrates our analytics integration on Cloud from PostHog to Amplitude.
This change updates the core AnalyticsHelper class to use the Amplitude
SDK while maintaining the same tracking interface. Rest of all existing
analytics calls throughout the codebase continue to work without
modification.

**Changes:**
- Replace PostHog analytics with Amplitude SDK
- Rename ANALYTICS_TOKEN to CLOUD_ANALYTICS_TOKEN for clarity
- Fix bug in page() method signature that was causing malformed payloads
2026-01-09 09:32:09 -08:00
PranavandGitHub ded2f2751a fix: Rendering of translations based on the user's locale (#13211)
Previously, translations were generated and resolved purely based on the
account locale. This caused issues in multi-team, multi-region setups
where agents often work in different languages than the account default.

For example, an account might be set to English, while an agent prefers
Spanish. In this setup:
- Translations were always created using the account locale.
- Agents could not view content in their preferred language.
- This did not scale well for global teams.

There was also an issue with locale resolution during rendering, where
the system would incorrectly default to the account locale even when a
more appropriate locale should have been used.

With this update, During rendering, the system first attempts to use the
agent’s locale. If a translation for that locale does not exist, it
falls back to the account locale.


**How to test:**

- Set agent locale to a specific language (e.g., zh_CN) and account
language to en.
  - Translate a message.
- Verify translated content displays correctly for the agent's selected
locale
  - Do the same for another locale for agent.
- With multiple translations on a message (e.g., zh_CN, es, ml), verify
the UI shows the one matching agent's locale
- Change agent locale and verify the displayed translation updates
accordingly
2026-01-08 18:37:42 -08:00
Sivin VargheseandGitHub ed0e87405c fix: Strip autolinks <...> when links are not supported (#13204)
# Pull Request Template

## Description

This PR fixes the crash that occurred when inserting canned responses
containing **autolinks** (e.g. `<https://example.com>`) into reply
channels that **do not support links**, such as **Twilio SMS**.

### Steps to reproduce

1. Create a canned response with an autolink, for example:
`<https://example.com>`.
2. Open a conversation in a channel that does not support links (e.g.
SMS).
3. Insert the canned response into the reply box.

### Cause

* Currently, only standard markdown links (`[text](url)`) are handled
when stripping unsupported formats from canned responses.
* Autolinks (`<https://example.com>`) are not handled during this
process.
* As a result, **Error: Token type link_open not supported by Markdown
parser**

### Solution

* Extended the markdown link parsing logic to explicitly handle
**autolinks** in addition to standard markdown links.
* When a canned response containing an autolink is inserted into a reply
box for a channel that does not support links (e.g. SMS), the angle
brackets (`< >`) are stripped.
* The autolink is safely pasted as **plain text URL**, preventing parser
errors and editor crashes.



Fixes
https://linear.app/chatwoot/issue/CW-6256/error-token-type-link-open-not-supported-by-markdown-parser
Sentry issues
[[1](https://chatwoot-p3.sentry.io/issues/7103543778/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream)],
[[2](https://chatwoot-p3.sentry.io/issues/7104325962/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream
)]


## Type of change

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


## 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
2026-01-08 18:37:52 +04:00
92422f979d chore: Replace plain editor with advanced editor (#13071)
# Pull Request Template

## Description

This PR reverts the plain text editor back to the **advanced editor**,
which was previously removed in
[https://github.com/chatwoot/chatwoot/pull/13058](https://github.com/chatwoot/chatwoot/pull/13058).
All channels now use the **ProseMirror editor**, with formatting applied
based on each channel’s configuration.

This PR also fixes issues where **new lines were not properly preserved
during Markdown serialization**, for both:

* `Enter or CMD/Ctrl+enter` (new paragraph)
* `Shift+Enter` (`hard_break`)

Additionally, it resolves related **[Sentry
issue](https://chatwoot-p3.sentry.io/issues/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-list&statsPeriod=7d)**.

With these changes:

* Line breaks and spacing are now preserved correctly when saving canned
responses.
* When editing a canned response, the content retains the exact spacing
and formatting as saved in editor.
* Canned responses are now correctly converted to plain text where
required and displayed consistently in the canned response list.

### https://github.com/chatwoot/prosemirror-schema/pull/38

---

## Type of change

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

## How Has This Been Tested?



## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-08 15:17:54 +05:30
Aakash BakhleandGitHub dcc72ee63c fix: consistent instrumentation with conversation.display_id (#13194) 2026-01-08 12:27:44 +05:30
PranavandGitHub 7ba7bf842e fix: Use SignedId instead of regular ID in portal update (#13197)
The Active Storage blob ids were handled inconsistently across the API.

- When a new logo was uploaded, the upload controller returned a
signed_id
- When loading an existing portal that already had a logo, the portal
model returned a blob_id.

This caused the API portal API to fail. There are about 107 instances of
this in production, so this change will fix that.
2026-01-07 19:36:29 -08:00
PranavandGitHub 69be587729 chore: Update copyright year in README.md to 2026 (#13195)
Basically what the title says
2026-01-07 17:42:41 -08:00
59cbf57e20 feat: Advanced Search Backend (#12917)
## Description

Implements comprehensive search functionality with advanced filtering
capabilities for Chatwoot (Linear: CW-5956).

This PR adds:
1. **Time-based filtering** for contacts and conversations (SQL-based
search)
2. **Advanced message search** with multiple filters
(OpenSearch/Elasticsearch-based)
- **`from` filter**: Filter messages by sender (format: `contact:42` or
`agent:5`)
   - **`inbox_id` filter**: Filter messages by specific inbox
- **Time range filters**: Filter messages using `since` and `until`
parameters (Unix timestamps in seconds)
- **90-day limit enforcement**: Automatically limits searches to the
last 90 days to prevent performance issues

The implementation extends the existing `Enterprise::SearchService`
module for advanced features and adds time filtering to the base
`SearchService` for SQL-based searches.

## API Documentation

### Base URL
All search endpoints follow this pattern:
```
GET /api/v1/accounts/{account_id}/search/{resource}
```

### Authentication
All requests require authentication headers:
```
api_access_token: YOUR_ACCESS_TOKEN
```

---

## 1. Search All Resources

**Endpoint:** `GET /api/v1/accounts/{account_id}/search`

Returns results from all searchable resources (contacts, conversations,
messages, articles).

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp (contacts/conversations only) | No
|
| `until` | integer | Unix timestamp (contacts/conversations only) | No
|

### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search?q=customer" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "contacts": [...],
    "conversations": [...],
    "messages": [...],
    "articles": [...]
  }
}
```

---

## 2. Search Contacts

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/contacts`

Search contacts by name, email, phone number, or identifier with
optional time filtering.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |

### Example Requests

**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search contacts active in the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search contacts active between 30 and 7 days ago:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "contacts": [
      {
        "id": 42,
        "email": "john@example.com",
        "name": "John Doe",
        "phone_number": "+1234567890",
        "identifier": "user_123",
        "additional_attributes": {},
        "created_at": 1701234567
      }
    ]
  }
}
```

---

## 3. Search Conversations

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/conversations`

Search conversations by display ID, contact name, email, phone number,
or identifier with optional time filtering.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |

### Example Requests

**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search conversations active in the last 24 hours:**
```bash
SINCE=$(date -v-1d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search conversations from last month:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "conversations": [
      {
        "id": 123,
        "display_id": 45,
        "inbox_id": 1,
        "status": "open",
        "messages": [...],
        "meta": {...}
      }
    ]
  }
}
```

---

## 4. Search Messages (Advanced)

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/messages`

Advanced message search with multiple filters powered by
OpenSearch/Elasticsearch.

### Prerequisites
- OpenSearch/Elasticsearch must be running (`OPENSEARCH_URL` env var
configured)
- Account must have `advanced_search` feature flag enabled
- Messages must be indexed in OpenSearch

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `from` | string | Filter by sender: `contact:{id}` or `agent:{id}` |
No |
| `inbox_id` | integer | Filter by specific inbox ID | No |
| `since` | integer | Unix timestamp - searches from this time (max 90
days ago) | No |
| `until` | integer | Unix timestamp - searches until this time | No |

### Important Notes
- **90-Day Limit**: If `since` is not provided, searches default to the
last 90 days
- If `since` exceeds 90 days, returns `422` error: "Search is limited to
the last 90 days"
- All time filters use message `created_at` timestamp

### Example Requests

**Basic message search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from a specific contact:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from a specific agent:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=agent:5" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages in a specific inbox:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&inbox_id=3" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages from the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Search messages between specific dates:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}&until=${UNTIL}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Combine all filters:**
```bash
SINCE=$(date -v-14d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42&inbox_id=3&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

**Attempt to search beyond 90 days (returns error):**
```bash
SINCE=$(date -v-120d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response (Success)
```json
{
  "payload": {
    "messages": [
      {
        "id": 789,
        "content": "I need a refund for my purchase",
        "message_type": "incoming",
        "created_at": 1701234567,
        "conversation_id": 123,
        "inbox_id": 3,
        "sender": {
          "id": 42,
          "type": "contact"
        }
      }
    ]
  }
}
```

### Example Response (90-day limit exceeded)
```json
{
  "error": "Search is limited to the last 90 days"
}
```
**Status Code:** `422 Unprocessable Entity`

---

## 5. Search Articles

**Endpoint:** `GET /api/v1/accounts/{account_id}/search/articles`

Search help center articles by title or content.

### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |

### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/articles?q=installation" \
  -H "api_access_token: YOUR_ACCESS_TOKEN"
```

### Example Response
```json
{
  "payload": {
    "articles": [
      {
        "id": 456,
        "title": "Installation Guide",
        "slug": "installation-guide",
        "portal_slug": "help",
        "account_id": 1,
        "category_name": "Getting Started",
        "status": "published",
        "updated_at": 1701234567
      }
    ]
  }
}
```

---

## Technical Implementation

### SQL-Based Search (Contacts, Conversations, Articles)
- Uses PostgreSQL `ILIKE` queries by default
- Optional GIN index support via `search_with_gin` feature flag for
better performance
- Time filtering uses `last_activity_at` for contacts/conversations
- Returns paginated results (15 per page)

### Advanced Search (Messages)
- Powered by OpenSearch/Elasticsearch via Searchkick gem
- Requires `OPENSEARCH_URL` environment variable
- Requires `advanced_search` account feature flag
- Enforces 90-day lookback limit via
`Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS`
- Validates inbox access permissions before filtering
- Returns paginated results (15 per page)

---

## Type of change

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

---

## How Has This Been Tested?

### Unit Tests
- **Contact Search Tests**: 3 new test cases for time filtering
(`since`, `until`, combined)
- **Conversation Search Tests**: 3 new test cases for time filtering
- **Message Search Tests**: 10+ test cases covering:
  - Individual filters (`from`, `inbox_id`, time range)
  - Combined filters
  - Permission validation for inbox access
  - Feature flag checks
  - 90-day limit enforcement
  - Error handling for exceeded time limits

### Test Commands
```bash
# Run all search controller tests
bundle exec rspec spec/controllers/api/v1/accounts/search_controller_spec.rb

# Run search service tests (includes enterprise specs)
bundle exec rspec spec/services/search_service_spec.rb
```

### Manual Testing Setup
A rake task is provided to create 50,000 test messages across multiple
inboxes:

```bash
# 1. Create test data
bundle exec rake search:setup_test_data

# 2. Start OpenSearch
mise elasticsearch-start

# 3. Reindex messages
rails runner "Message.search_index.import Message.all"

# 4. Enable feature flag
rails runner "Account.first.enable_features('advanced_search')"

# 5. Test via API or Rails console
```

---

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation (this PR
description)
- [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

---

## Additional Notes

### Requirements
- **OpenSearch/Elasticsearch**: Required for advanced message search
  - Set `OPENSEARCH_URL` environment variable
  - Example: `export OPENSEARCH_URL=http://localhost:9200`
- **Feature Flags**:
  - `advanced_search`: Account-level flag for message advanced search
- `search_with_gin` (optional): Account-level flag for GIN-based SQL
search

### Performance Considerations
- 90-day limit prevents expensive long-range queries on large datasets
- GIN indexes recommended for high-volume search on SQL-based resources
- OpenSearch/Elasticsearch provides faster full-text search for messages

### Breaking Changes
- None. All new parameters are optional and backward compatible.

### Frontend Integration
- Frontend PR tracking advanced search UI will consume these endpoints
- Time range pickers should convert JavaScript `Date` to Unix timestamps
(seconds)
- Date conversion: `Math.floor(date.getTime() / 1000)`

### Error Handling
- Invalid `from` parameter format is silently ignored (filter not
applied)
- Time range exceeding 90 days returns `422` with error message
- Missing `q` parameter returns `422` (existing behavior)
- Unauthorized inbox access is filtered out (no error, just excluded
from results)

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-07 15:30:49 +05:30
Shivam MishraandGitHub 566de02385 feat: allow agent bot and captain responses to reset waiting since (#13181)
When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.

### Timeline from Production Data

```
Dec 12, 16:20:14 - Customer sends message (ID: 368451924)
                   ↓ waiting_since = Dec 12, 16:20:14

Dec 12, 16:20:17 - AgentBot replies (ID: 368451960)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 
                   ↓ (Bot response doesn't clear it)

14-day gap        - Customer satisfied, no messages
                   ↓ waiting_since STILL = Dec 12, 16:20:14 

Dec 26, 22:25:45 - Customer sends new message (ID: 383522275)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 
                   ↓ (New message doesn't reset it)

Dec 26-27         - More AgentBot interactions
                   ↓ waiting_since STILL = Dec 12, 16:20:14 

Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517)
                   ↓ Reply time calculated: 1,268,404 seconds
                   ↓ = 14.7 DAYS 
```
## Root Cause

The core issues is in `app/models/message.rb`, where **AgentBot messages
does not clear `waiting_since`** - The `human_response?` method only
returns true for `User` senders, so bot replies never trigger the
clearing logic. This means once `waiting_since` is set, it stays set
even when customers send new messages after receiving bot responses.

The solution is to simply reset `waiting_since` **after a bot has
responded**. This ensures reply time metrics reflect actual human agent
response times, not bot-handled periods.

### What triggers the rest

This is an intentional "gotcha", that only `AgentBot` and
`Captain::Assistant` messages trigger the waiting time reset. Automation
and campaign messages maintain current behavior (no reset). This is
because interactive bot assistants provide conversational help that
might satisfy customers. Automation and campaigns are one-way
communications and shouldn't affect waiting time calculations.

## Related Work

Extends PR #11787 which fixed `waiting_since` clearing on conversation
resolution. This PR addresses the bot interaction scenario which was not
covered by that fix.

Scripts to clean data:
https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a
2026-01-07 13:57:43 +05:30
Shivam MishraandGitHub 02ab856520 feat(CW-6187): include headers from incoming emails (#13139) 2026-01-07 12:45:54 +05:30
Tanmay Deep SharmaandGitHub e58600d1b9 fix: the webhook url to be text (#13157)
## Description
Change the url type from string to text, to support more than 255
characters

Fixes # (issue)
https://app.chatwoot.com/app/accounts/1/conversations/65240

## Type of change

Please delete options that are not relevant.

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


## 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
2026-01-06 15:23:54 +05:30
3e5b2979eb feat: Add support for sending CSAT surveys via templates (Whatsapp Cloud) (#12787)
This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp
message templates, ensuring survey delivery even after the 24-hour
session window.

The system now automatically creates, updates, and monitors WhatsApp
CSAT templates without manual intervention.

<img width="1664" height="1792" alt="approved"
src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975"
/>

#### Why this change

Previously, WhatsApp CSAT messages failed outside the 24-hour customer
window.

With this update:

- CSAT surveys are delivered reliably using WhatsApp templates
- Template creation happens automatically in the background
- Users can modify survey content and recreate templates easily
- Clear UI states show template approval status

#### Screens & States

<details>
<summary>Default — No template configured yet</summary>
<img width="1662" height="1788" alt="default"
src="https://github.com/user-attachments/assets/ed26d71b-cf7c-4a26-a2af-da88772c847c"
/>
</details>
<details>
<summary>Pending — Template submitted, awaiting Meta approval</summary>
<img width="1658" height="1816" alt="pending"
src="https://github.com/user-attachments/assets/923b789b-d91b-4364-905d-e56a2b65331a"
/>
</details>
<details>
<summary>Approved — Survey will be sent when conversation
resolves</summary>
<img width="1664" height="1792" alt="approved"
src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975"
/>
</details>
<details>
<summary>Rejected — Template rejected by Meta</summary>
<img width="1672" height="1776" alt="rejected"
src="https://github.com/user-attachments/assets/f69a9b0e-be27-4e67-a993-7b8149502c4f"
/>
</details>
<details>
<summary>Not Found — Template missing in Meta Platform</summary>
<img width="1660" height="1784" alt="not-exist"
src="https://github.com/user-attachments/assets/a2a4b4f7-b01a-4424-8fcb-3ed84256e057"
/>
</details>
<details>
<summary>Edit Template — Delete & recreate template on change</summary>
<img width="2342" height="1778" alt="edit-survey"
src="https://github.com/user-attachments/assets/0f999285-0341-4226-84e9-31f0c6446924"
/>
</details>

#### Test Cases


**1. First-time CSAT setup on WhatsApp inbox**

- Enable CSAT
- Enter message + button text
- Save
- Expected: Template created automatically, UI shows pending state

**2. CSAT toggle without changing text**

- Existing approved template
- Toggle CSAT OFF → ON (no text change)
- Expected: No confirmation alert, no template recreation

**3. Editing only survey rules**

- Modify labels or rule conditions only
- Expected: No confirmation alert, template remains unchanged

**4. Template text change**

- Change survey message or button text
- Save
- Expected:
    - Confirmation dialog shown
    - On confirm → previous template deleted, new one created
    - On cancel → revert to previous values

**5. Language change**

- Change template language (e.g., en → es)
- Expected: Confirmation dialog + new template on confirm

 **6. Sending survey**

- Template approved → always send template
- Template pending → send free-form within 24 hours only
- Template rejected/missing → fallback to free-form (if within window)
- Outside 24 hours & no approved template → activity log only

**7. Non-WhatsApp inbox**

- Enable CSAT for email/web inbox
- Expected: No template logic triggered


Fixes
https://linear.app/chatwoot/issue/CW-6188/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-01-06 11:46:00 +04:00
Muhsin KelothandGitHub bd698cb12c feat: Add call-to-action template support for Twilio (#13179)
Fixes
https://linear.app/chatwoot/issue/CW-6228/add-call-to-action-template-support-for-twilio-whatsapp-templates

Adds support for Twilio WhatsApp call-to-action templates, enabling
customers to use URL button templates with variable inputs.

<img width="2982" height="1388" alt="CleanShot 2026-01-05 at 16 25
55@2x"
src="https://github.com/user-attachments/assets/7cf332f5-3f3e-4ffb-a461-71c60a0156c8"
/>
2026-01-06 10:38:36 +04:00
PranavandGitHub 79381a4c5f fix: Add code_block method to WhatsApp and Instagram markdown renderers (#13166)
Problem: SystemStackError: stack level too deep occurred when rendering
messages with indented content (4+ spaces) for WhatsApp, Instagram, and
Facebook channels.

Root Cause: CommonMarker::Renderer#code_block contains a self-recursive
placeholder that must be overridden:
```
  def code_block(node)
    code_block(node)  # calls itself infinitely
  end
```

WhatsAppRenderer and InstagramRenderer were missing this override,
causing infinite recursion when markdown with 4-space indentation
(interpreted as code blocks) was rendered.

Fix: Added code_block method to both renderers that outputs the node
content as plain text:
```
  def code_block(node)
    out(node.string_content)
  end
 ```

Fix https://linear.app/chatwoot/issue/CW-6217/systemstackerror-stack-level-too-deep-systemstackerror
2025-12-30 11:25:54 -08:00
d57170712d fix: increase the alfred connection pool size to 10 (#13138)
## Description
Increased the alfred pool size to 10, so that each worker has enough
redis connection thread pool to work

## Type of change

Please delete options that are not relevant.

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


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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces configurability for the Redis pool used by `alfred`.
> 
> - `$alfred` `ConnectionPool` size now reads from
`ENV['REDIS_ALFRED_SIZE']` with a default of `5`
> - Adds `REDIS_ALFRED_SIZE=10` to `.env.example`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
96cdff8c0ea40f82a57d70be053780e87384ed47. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-23 13:59:20 +07:00
Sojan Jose e7be8c14bd Merge branch 'release/4.9.1' into develop 2025-12-22 18:56:15 -08:00
1060 changed files with 34806 additions and 9774 deletions
+54 -10
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -144,11 +144,11 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 18
steps:
- checkout
- node/install:
node-version: '23.7'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -218,6 +218,49 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -234,6 +277,7 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
@@ -306,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+3
View File
@@ -274,3 +274,6 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 24
cache: 'pnpm'
- name: pnpm
+2
View File
@@ -94,6 +94,7 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
CLAUDE.local.md
# Histoire deployment
@@ -101,3 +102,4 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
+1 -1
View File
@@ -1 +1 @@
23.7.0
24.13.0
+16
View File
@@ -4,6 +4,11 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -50,6 +55,13 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
@@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+3 -1
View File
@@ -191,12 +191,14 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.7.0'
gem 'ai-agents'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
+23 -19
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
ai-agents (0.9.0)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -186,6 +186,7 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -297,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -308,12 +309,12 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -441,7 +442,8 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.21.0)
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
@@ -463,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.13.2)
json (2.18.1)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -538,11 +540,11 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
maxminddb (0.1.22)
meta_request (0.8.3)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
railties (>= 3.0.0, < 9)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
@@ -555,13 +557,14 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
uri
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -822,7 +825,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.8.2)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -966,7 +969,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.4)
uri (1.1.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1001,7 +1004,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.17)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1021,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
ai-agents
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1035,6 +1038,7 @@ DEPENDENCIES
bullet
bundle-audit
byebug
cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
+1 -2
View File
@@ -8,7 +8,6 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.9.1
4.11.0
+1 -1
View File
@@ -1 +1 @@
3.4.3
3.5.0
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -0,0 +1,38 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -0,0 +1,68 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -0,0 +1,65 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -0,0 +1,79 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -0,0 +1,76 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
@contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -70,8 +70,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok
end
@@ -110,6 +113,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -142,12 +154,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
params.permit(*permitted_settings_attributes)
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
@@ -35,12 +35,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
end
@@ -77,6 +76,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -57,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -134,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end
+1 -1
View File
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN
CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
end
def update
+7
View File
@@ -0,0 +1,7 @@
# Inherits from ActionController::Base to skip all middleware,
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
end
end
@@ -42,19 +42,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
@@ -0,0 +1,35 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_conversations_report
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
summary = builder.summary
[generate_conversation_report_metrics(summary)]
end
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
def generate_conversation_report_metrics(summary)
[
summary[:conversations_count],
summary[:incoming_messages_count],
summary[:outgoing_messages_count],
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
summary[:resolutions_count],
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
]
end
end
+6 -7
View File
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
+1 -1
View File
@@ -131,7 +131,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
@@ -0,0 +1,107 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* A client for the Captain Tasks API.
* @extends ApiClient
*/
class TasksAPI extends ApiClient {
/**
* Creates a new TasksAPI instance.
*/
constructor() {
super('captain/tasks', { accountScoped: true });
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
* @param {string} options.content - The content to rewrite.
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the rewritten content.
*/
rewrite({ content, operation, conversationId }, signal) {
return axios.post(
`${this.url}/rewrite`,
{
content,
operation,
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary.
*/
summarize(conversationId, signal) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets a reply suggestion for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the reply suggestion.
*/
replySuggestion(conversationId, signal) {
return axios.post(
`${this.url}/reply_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
+10
View File
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
@@ -1,81 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* Represents the data object for a OpenAI hook.
* @typedef {Object} ConversationMessageData
* @property {string} [tone] - The tone of the message.
* @property {string} [content] - The content of the message.
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
*/
/**
* A client for the OpenAI API.
* @extends ApiClient
*/
class OpenAIAPI extends ApiClient {
/**
* Creates a new OpenAIAPI instance.
*/
constructor() {
super('integrations', { accountScoped: true });
/**
* The conversation events supported by the API.
* @type {string[]}
*/
this.conversation_events = [
'summarize',
'reply_suggestion',
'label_suggestion',
];
/**
* The message events supported by the API.
* @type {string[]}
*/
this.message_events = ['rephrase'];
}
/**
* Processes an event using the OpenAI API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='rephrase'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.tone] - The tone of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {string} options.hookId - The ID of the hook to use for processing the event.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
/**
* @type {ConversationMessageData}
*/
let data = {
tone,
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
+6
View File
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/conversations_summary`, {
params: { since, until, business_hours: businessHours },
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
+14 -4
View File
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1 }) {
contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1 }) {
conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1 }) {
messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1 }) {
articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -0,0 +1,134 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -94,21 +94,52 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 247 247 247;
--surface-1: 254 254 254;
--surface-2: 255 255 255;
--surface-active: 255 255 255;
--background-input-box: 0, 0, 0, 0.03;
--text-blue: 1 22 44;
--text-purple: 2 4 49;
--text-amber: 37 24 1;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-strong: 226 227 231;
--border-weak: 234 234 234;
--border-blue-strong: 18 61 117;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-amber: 255 228 181;
--solid-blue: 218 236 255;
--solid-blue-2: 251 253 255;
--solid-iris: 230 231 255;
--solid-purple: 230 231 255;
--solid-red: 254 200 201;
--solid-amber-button: 255 221 141;
--card-color: 255 255 255;
--overlay: 0, 0, 0, 0.12;
--overlay-avatar: 255, 255, 255, 0.67;
--button-color: 255 255 255;
--button-hover-color: 255, 255, 255, 0.2;
--label-background: 247 247 247;
--label-border: 0, 0, 0, 0.04;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-1: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -209,25 +240,56 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 28 29 32;
--surface-1: 20 21 23;
--surface-2: 22 23 26;
--surface-active: 53 57 66;
--background-input-box: 255, 255, 255, 0.02;
--text-blue: 213 234 255;
--text-purple: 232 233 254;
--text-amber: 255 247 234;
--border-strong: 46 45 50;
--border-weak: 31 31 37;
--border-blue-strong: 201 226 255;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-amber: 56 50 41;
--solid-blue: 15 57 102;
--solid-blue-2: 26 29 35;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--solid-purple: 51 51 107;
--solid-red: 90 33 34;
--solid-amber-button: 255 221 141;
--card-color: 28 30 34;
--overlay: 0, 0, 0, 0.4;
--overlay-avatar: 0, 0, 0, 0.05;
--button-color: 42 43 51;
--button-hover-color: 0, 0, 0, 0.15;
--label-background: 36 38 45;
--label-border: 255, 255, 255, 0.03;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
--alpha-3: 33, 34, 38, 0.95;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-text;
@apply text-n-blue-11;
}
}
}
@@ -39,7 +39,6 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
@@ -50,7 +49,6 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
@@ -15,7 +15,6 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
@@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
{{ name }}
</h3>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
@@ -19,11 +19,15 @@ defineProps({
},
});
const emit = defineEmits(['delete']);
const emit = defineEmits(['delete', 'navigate']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
{{ item.name }}
</span>
</div>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
},
});
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
};
onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value);
windowUnit.value = detectUnit(windowInMinutes.value);
});
</script>
@@ -73,9 +87,9 @@ onMounted(() => {
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<!-- allow 10 mins to 999 days (in minutes) -->
<DurationInput
v-model:model-value="fairDistributionWindow"
v-model:model-value="windowInMinutes"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -1,4 +1,6 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
@@ -16,12 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive) {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
}
};
@@ -29,9 +41,11 @@ const handleChange = () => {
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
@@ -41,6 +55,7 @@ const handleChange = () => {
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
@@ -49,11 +64,23 @@ const handleChange = () => {
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11">
{{ description }}
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
</div>
</div>
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -19,13 +18,10 @@ const enabled = ref(true);
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -35,6 +35,10 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
];
const orderingMenus = [
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
@@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
class="text-n-blue-11"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
const openCreateNewContactDialog = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -19,6 +20,9 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -27,6 +31,7 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -61,11 +66,19 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -94,9 +107,14 @@ const openFilter = () => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -0,0 +1,28 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
@@ -0,0 +1,17 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { ATTRIBUTE_TYPES } from './constants';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
[ATTRIBUTE_TYPES.TEXT]: 'i-lucide-align-justify',
[ATTRIBUTE_TYPES.CHECKBOX]: 'i-lucide-circle-check-big',
[ATTRIBUTE_TYPES.LIST]: 'i-lucide-list',
[ATTRIBUTE_TYPES.DATE]: 'i-lucide-calendar',
[ATTRIBUTE_TYPES.LINK]: 'i-lucide-link',
[ATTRIBUTE_TYPES.NUMBER]: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>
@@ -0,0 +1,186 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
isEnabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const router = useRouter();
const { t } = useI18n();
const { currentAccount, accountId, isOnChatwootCloud, updateAccount } =
useAccount();
const [showDropdown, toggleDropdown] = useToggle(false);
const [isSaving, toggleSaving] = useToggle(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
const showPaywall = computed(() => !props.isEnabled && isOnChatwootCloud.value);
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() => {
const selectedKeysSet = new Set(selectedAttributeKeys.value);
return allAttributeOptions.value.filter(
attribute => !selectedKeysSet.has(attribute.value)
);
});
const conversationRequiredAttributes = computed(() => {
const attributeMap = new Map(
allAttributeOptions.value.map(attr => [attr.value, attr])
);
return selectedAttributeKeys.value
.map(key => attributeMap.get(key))
.filter(Boolean);
});
const handleAddAttributesClick = event => {
event.stopPropagation();
toggleDropdown();
};
const saveRequiredAttributes = async keys => {
try {
toggleSaving(true);
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
toggleSaving(false);
toggleDropdown(false);
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
toggleDropdown(false);
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<div
v-if="isEnabled || showPaywall"
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
@click="handleClick"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
<div v-if="isEnabled" v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
</div>
<template v-if="isEnabled">
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</template>
<BasePaywallModal
v-else
class="mx-auto my-8"
feature-prefix="CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@upgrade="goToBillingSettings"
/>
</div>
</template>
@@ -0,0 +1,248 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, url, helpers } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
import { ATTRIBUTE_TYPES } from './constants';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = reactive({});
const conversationContext = ref(null);
const placeholders = computed(() => ({
text: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'),
date: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'),
list: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'),
}));
const getPlaceholder = type => placeholders.value[type] || '';
const validationRules = computed(() => {
const rules = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LINK) {
rules[attribute.value] = { required, url };
} else if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
// Checkbox doesn't need validation - any selection is valid
rules[attribute.value] = {};
} else {
rules[attribute.value] = { required };
if (attribute.regexPattern) {
rules[attribute.value].regexValidation = helpers.withParams(
{ regexCue: attribute.regexCue },
value => !value || getRegexp(attribute.regexPattern).test(value)
);
}
}
});
return rules;
});
const v$ = useVuelidate(validationRules, formValues);
const getErrorMessage = attributeKey => {
const field = v$.value[attributeKey];
if (!field || !field.$error) return '';
if (field.url && field.url.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (field.regexValidation && field.regexValidation.$invalid) {
return (
field.regexValidation.$params?.regexCue ||
t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (field.required && field.required.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}
return '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues[attribute.value];
// For checkbox attributes, ensure the agent has explicitly selected a value
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return formValues[attribute.value] !== null;
}
// For other attribute types, check for valid non-empty values
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const comboBoxOptions = computed(() => {
const options = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LIST) {
options[attribute.value] = (attribute.attributeValues || []).map(
option => ({
value: option,
label: option,
})
);
}
});
return options;
});
const close = () => {
dialogRef.value?.close();
conversationContext.value = null;
v$.value.$reset();
};
const open = (attributes = [], initialValues = {}, context = null) => {
visibleAttributes.value = attributes;
conversationContext.value = context;
// Clear existing formValues
Object.keys(formValues).forEach(key => {
delete formValues[key];
});
// Initialize form values
attributes.forEach(attribute => {
const presetValue = initialValues[attribute.value];
if (presetValue !== undefined && presetValue !== null) {
formValues[attribute.value] = presetValue;
} else {
// For checkbox attributes, initialize to null to avoid pre-selection
// For other attributes, initialize to empty string
formValues[attribute.value] =
attribute.type === ATTRIBUTE_TYPES.CHECKBOX ? null : '';
}
});
v$.value.$reset();
dialogRef.value?.open();
};
const handleConfirm = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', {
attributes: { ...formValues },
context: conversationContext.value,
});
close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:disable-confirm-button="!isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === ATTRIBUTE_TYPES.TEXT">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.TEXT)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.NUMBER">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.NUMBER)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LINK">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LINK)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.DATE">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.DATE)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LIST">
<ComboBox
v-model="formValues[attribute.value]"
:options="comboBoxOptions[attribute.value]"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LIST)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
:has-error="v$[attribute.value].$error"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.CHECKBOX">
<ChoiceToggle v-model="formValues[attribute.value]" />
</template>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,8 @@
export const ATTRIBUTE_TYPES = {
TEXT: 'text',
NUMBER: 'number',
LINK: 'link',
DATE: 'date',
LIST: 'list',
CHECKBOX: 'checkbox',
};
@@ -128,7 +128,7 @@ const handleInputUpdate = async () => {
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
'truncate hover:text-n-brand text-n-blue-11':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
@@ -96,6 +97,7 @@ watch(
]"
>
<WootEditor
:editor-id="editorKey"
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
@@ -152,6 +154,13 @@ watch(
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -37,7 +37,7 @@ defineProps({
<div
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
showBackdrop,
}"
>
@@ -48,14 +48,12 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
<h2 class="text-3xl font-medium text-center text-n-slate-12">
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -126,7 +126,7 @@ const handleClick = id => {
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
@@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
@@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
@@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
const [icon = '', color = 'text-n-blue-11'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
>
{{ snoozedText }}
</span>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
@@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
@@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
@@ -160,6 +179,12 @@ const closeCompose = () => {
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
discardCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
@@ -193,7 +218,11 @@ watch(
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
@@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
@@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
@discard="discardCompose"
/>
</div>
</div>
@@ -44,6 +44,12 @@ const emit = defineEmits([
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
@@ -176,7 +182,8 @@ const onPaste = e => {
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
});
};
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -7,6 +7,7 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -36,6 +37,7 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
@@ -47,6 +49,8 @@ const emit = defineEmits([
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -54,13 +58,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = reactive({
const state = props.formState || {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -198,10 +202,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
@@ -221,7 +237,9 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -324,67 +342,68 @@ const shouldShowMessageEditor = computed(() => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<ActionButtons
:attached-files="state.attachedFiles"
@@ -95,6 +95,7 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@@ -127,6 +128,7 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
@@ -24,14 +23,14 @@ const modelValue = defineModel({
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<div class="flex-1 h-full">
<Editor
:key="editorKey"
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
@@ -103,11 +103,11 @@ const STYLE_CONFIG = {
solid:
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-11 outline-n-brand',
ghost:
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid:
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
@@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
@@ -141,7 +141,7 @@ const onClickCancel = () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
@@ -169,7 +169,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -254,7 +254,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -220,7 +220,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@@ -100,7 +100,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -116,7 +116,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -34,7 +34,7 @@ const handleImgClick = () => {
<template>
<div
data-testid="changelog-card"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
:class="{
'animate-fade-out pointer-events-none': isDismissing,
'hover:shadow': isActive,
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
});
const selectOption = option => {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
if (selectedValue.value === option.value) {
selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
open.value = false;
search.value = '';
};

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