Compare commits

..
Author SHA1 Message Date
aakashb95 01c84be757 add improve 2025-12-15 16:15:33 +05:30
aakashb95 5ac32803dc remove redundant checks 2025-12-15 15:11:10 +05:30
Aakash BakhleandGitHub 18e0cfb327 Merge branch 'feature/cw-6117' into feature/cw-6118 2025-12-15 15:07:54 +05:30
Vishnu NarayananandGitHub a70d331813 Merge branch 'develop' into feature/cw-6117 2025-12-12 18:54:13 +05:30
Vishnu NarayananandGitHub 26b4a24f11 fix: linear and user association spec (#13056)
- Linear::CallbacksController: Replace broken
`described_class.new`mocking with proper `GlobalConfigService` stubbing
and real JWT token generation. The old pattern doesn't work in request
specs since Rails instantiates controllers internally.
- User associations: Remove `.class_name('Conversation')` assertion that
fails intermittently due to enterprise `prepend_mod_with` timing in
parallel tests. The class_name is already enforced by Rails at runtime -
if wrong, the app would crash immediately. No need to explicitly test
for this

Fixes
https://linear.app/chatwoot/issue/CW-6138/debug-linear-and-user-spec-failures-in-ci
2025-12-12 18:53:26 +05:30
aakashb95 3b4c5bd916 remove redundant logic 2025-12-12 16:43:11 +05:30
aakashb95 491e7d0c6c Merge branch 'feature/cw-6117' into feature/cw-6118 2025-12-12 16:36:58 +05:30
a8ed074bf0 fix: Preserve double newlines in text-based messaging channels (#13055)
## Summary

Fixes the issue where double newlines (paragraph breaks) were collapsing
to single newlines in text-based messaging channels (Telegram, WhatsApp,
Instagram, Facebook, LINE, SMS).

### Root Cause

The `preserve_multiple_newlines` method only preserved 3+ consecutive
newlines using the regex `/\n{3,}/`. When users pressed Enter twice
(creating a paragraph break with 2 newlines), CommonMarker would parse
this as separate paragraphs, which then collapsed to a single newline in
the output.

This caused:
-  Normal Enter: Double newlines collapsed to single newline
-  Shift+Enter: Worked (created hard breaks)

### Fix

Changed the regex from `/\n{3,}/` to `/\n{2,}/` to preserve 2+
consecutive newlines. This prevents CommonMarker from collapsing
paragraph breaks.

Now:
-  Single newline (`\n`) → Single newline (handled by softbreak)
-  Double newline (`\n\n`) → Double newline (preserved with
placeholders)
-  Triple+ newlines → Preserved as before

### Test Coverage

Added comprehensive tests for:
- Single newlines preservation
- Double newlines (paragraph breaks) preservation  
- Multiple consecutive newlines
- Newlines with varying amounts of whitespace between them (1 space, 3
spaces, 5 spaces, tabs)

All 66 tests passing.

### Impact

This fix affects all text-based messaging channels that use the markdown
renderer:
- Telegram
- WhatsApp
- Instagram  
- Facebook
- LINE
- SMS
- Twilio SMS (when configured for WhatsApp)

Fixes
https://linear.app/chatwoot/issue/CW-6135/double-newline-is-breaking

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-12-12 16:35:53 +05:30
aakashb95 e81b812ac2 ui changes 2025-12-12 16:34:52 +05:30
aakashb95 baec4523d5 add improve prompts 2025-12-12 16:27:43 +05:30
Aakash BakhleandGitHub 65136c90d8 Merge branch 'develop' into feature/cw-6117 2025-12-12 14:45:57 +05:30
aakashb95 24125e2d9a move agent instruction for tone to file 2025-12-12 14:39:58 +05:30
aakashb95 52e3642127 fix rubocop 2025-12-12 14:39:46 +05:30
aakashb95 6e13eb965b fix specs 2025-12-12 14:28:22 +05:30
aakashb95 7d78f67d3b fix ent override 2025-12-12 14:15:04 +05:30
aakashb95 a7420f8fae update specs 2025-12-12 14:00:47 +05:30
aakashb95 847ce98006 add confident tone 2025-12-12 14:00:40 +05:30
Sivin VargheseandGitHub 96fe3e146d chore: Clean up reply box component (#13060) 2025-12-12 10:50:02 +05:30
Sivin VargheseandGitHub 696564863c feat: Add plain-text editor for non-rich content channels (#13058)
# Pull Request Template

## Description

This PR restores the plain text editor for all channels except Website,
Email, and API.

## 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
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-12-12 10:14:22 +05:30
aakashb95 8b9da2e409 include keys for tones 2025-12-11 22:49:57 +05:30
aakashb95 d540d885f2 base prompt to improve editor replies 2025-12-11 22:49:46 +05:30
df4c8cf58b chore: Strip unsupported signature formatting by channel (#13046)
# Pull Request Template

## Description

1. This PR is an enhancement to
https://github.com/chatwoot/chatwoot/pull/13045
It strips unsupported formatting from **message signatures** based on
each channel’s formatting capabilities defined in the `FORMATTING`
config

2. Remove usage of plain editor in Compose new conversation modal

Only the following signature elements are considered:
<strong>bold (<code inline="">strong</code>), italic (<code
inline="">em</code>), links (<code inline="">link</code>), images (<code
inline="">image</code>)</strong>.</p>

Any formatting not supported by the target channel is automatically
removed before the signature is appended.

<h3>Channel-wise Signature Formatting Support</h3>

Channel | Keeps in Signature | Strips from Signature
-- | -- | --
Email | bold, italic, links, images | —
WebWidget | bold, italic, links, images | —
API | bold, italic | links, images
WhatsApp | bold, italic | links, images
Telegram | bold, italic, links | images
Facebook | bold, italic | links, images
Instagram | bold, italic | links, images
Line | bold, italic | links, images
SMS | — | everything
Twilio SMS | — | everything
Twitter/X | — | everything


<hr>
<h3>📝 Note</h3>
<blockquote>
<p>Message signatures only support <strong>bold, italic, links, and
images</strong>.<br>
Other formatting options available in the editor (lists, code blocks,
strike-through, etc.) do <strong>not apply</strong> to signatures and
are ignored.</p>
</blockquote>

## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd


## 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>
2025-12-11 19:58:59 +05:30
2bd8e76886 feat: Add backend changes for whatsapp csat template (#12984)
This PR add the backend changes for the feature [sending CSAT surveys
via WhatsApp message templates
](https://github.com/chatwoot/chatwoot/pull/12787)

---------

Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2025-12-11 16:36:37 +05:30
Aakash BakhleandGitHub 1de8d3e56d feat: legacy features to ruby llm (#12994) 2025-12-11 14:17:28 +05:30
Sivin VargheseandGitHub f2054e703a fix: Handle rich message signatures & attachment overflow (#13045) 2025-12-10 23:13:04 +05:30
Vinay KeerthiandGitHub 89d02e2c92 fix: Preserve multiple newlines with whitespace in text-based messaging channels (#13044)
## Description

Fixes an issue where multiple newlines with whitespace between them
(e.g., `\n \n \n`) were being collapsed to single newlines in text-based
messaging channels (Telegram, WhatsApp, Instagram, Facebook, Line, SMS).

The frontend was sending messages with spaces/tabs between newlines, and
the markdown renderer was treating these as paragraph content,
collapsing them during rendering.

### Changes:
1. Added whitespace normalization in `render_telegram_html`,
`render_whatsapp`, `render_instagram`, `render_line`, and
`render_plain_text` methods
2. Strips whitespace from whitespace-only lines before markdown
processing
3. Added comprehensive regression tests for all affected channels

## Type of change

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

## How Has This Been Tested?

1. **Unit Tests**: Added 7 new specs testing multiple newlines with
whitespace between them for all text-based channels
2. **Manual Testing**: Verified with actual frontend payload containing
`\n \n \n` patterns
3. **Regression Testing**: All existing 63 specs pass

### Test Results:
-  All 63 markdown renderer specs pass (56 original + 7 new)
-  All 12 Telegram channel specs pass
-  All 27 WhatsApp + Instagram specs pass
-  Verified with real-world payload: 18 newlines preserved (previously
collapsed to 1)

### Test Command:
```bash
RAILS_ENV=test bundle exec rspec spec/services/messages/markdown_renderer_service_spec.rb
RAILS_ENV=test bundle exec rspec spec/models/channel/telegram_spec.rb
```

## 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
- [x] New and existing unit tests pass locally with my changes
2025-12-10 21:44:16 +05:30
Shivam MishraandGitHub 0d8e249fe4 feat: include chatwoot metadata with each tool call (#12907) 2025-12-10 15:25:18 +05:30
Muhsin KelothandGitHub 20fa5eeaa5 fix: Prevent SLA deletion timeouts by moving to async job (#12944)
This PR fixes the HTTP 500 timeout errors occurring when deleting SLA
policies that have large volumes of historical data.
The fix moves the deletion workflow to asynchronous background
processing using the existing `DeleteObjectJob`.
By offloading heavy cascaded deletions (applied SLAs, SLA events,
conversation nullifications) from the request cycle, the API can now
return immediately while the cleanup continues in the background
avoiding the `Rack::Timeout::RequestTimeoutException`. This ensures that
SLA policies can be deleted reliably, regardless of data size.


### Problem
Deleting an SLA policy via `DELETE
/api/v1/accounts/{account_id}/sla_policies/{id}` fails consistently with
`Rack::Timeout::RequestTimeoutException (15s)` for policies with large
amounts of related data.

Because the current implementation performs all dependent deletions
**synchronously**, Rails processes:

- `has_many :applied_slas, dependent: :destroy` (thousands)
- Each `AppliedSla#destroy` → triggers destruction of many `SlaEvent`
records
- `has_many :conversations, dependent: :nullify` (thousands)

This processing far exceeds the Rack timeout window and consistently
triggers HTTP 500 errors for users.

### Solution

This PR applies the same pattern used successfully in Inbox deletion.

**Move deletion to async background jobs**

- Uses `DeleteObjectJob` for centralized, reliable cleanup.
- Allows the DELETE API call to respond immediately.

**Chunk large datasets**

- Records are processed in **batches of 5,000** to reduce DB load and
avoid job timeouts.
2025-12-10 12:28:47 +05:30
Vinay KeerthiandGitHub f2eaa845dc fix: Preserve multiple consecutive newlines in text-based messaging channels (#13032)
## Description

This PR fixes an issue where multiple consecutive newlines (blank lines
for visual spacing) were being collapsed in text-based messaging
channels like WhatsApp, Instagram, and SMS.

When users send messages via API with intentional spacing using multiple
newlines (e.g., `\n\n\n\n`), the markdown renderer was following
standard Markdown spec and collapsing them into single blank lines.
While this is correct for document formatting, messaging platforms like
WhatsApp and Instagram support and preserve multiple blank lines for
visual spacing.

The fix adds preprocessing to preserve multiple consecutive newlines
(3+) by converting them to placeholder tokens before CommonMarker
processing, then restoring the exact number of newlines in the final
output.

## Changes

- Added `preserve_multiple_newlines` and `restore_multiple_newlines`
helper methods to `MarkdownRendererService`
- Updated `render_whatsapp` to preserve multiple consecutive newlines
- Updated `render_instagram` to preserve multiple consecutive newlines
- Updated `render_plain_text` (affects SMS, Twilio SMS, Twitter) to
preserve multiple consecutive newlines
- Updated `render_line` to preserve multiple consecutive newlines
- HTML-based renderers (Email, Telegram, WebWidget) remain unchanged as
they handle spacing via HTML tags

## Type of change

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

Added comprehensive test coverage:
- 3 new tests for multi-newline preservation across WhatsApp, Instagram,
and SMS channels
- All 56 tests passing (up from 53)

Testing scenarios:
- Single newlines preserved: `"Line 1\nLine 2"` remains `"Line 1\nLine
2"`
- Multiple newlines preserved: `"Para 1\n\n\n\nPara 2"` remains `"Para
1\n\n\n\nPara 2"`
- Standard paragraph breaks (2 newlines) work as before
- Markdown formatting (bold, italic, links) continues to work correctly
- Backward compatibility maintained for all channels

## 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
2025-12-09 17:45:27 +05:30
Sivin VargheseandGitHub 863c033699 fix: Strip unsupported markdown formatting from canned responses (#13028)
# Pull Request Template

## Description

This PR fixes, 
1. **Issue with canned response insertion** - Canned responses with
formatting (bold, italic, code, lists, etc.) were not being inserted
into channels that don't support that formatting.
Now unsupported markdown syntax is automatically stripped based on the
channel's schema before insertion.
2. **Make image node optional** - Images are now stripped while paste.
https://github.com/chatwoot/prosemirror-schema/pull/36/commits/9e269fca04db07eb1dc09ebfab051c5ae70124d7
3. Enable **bold** and _italic_ for API channel

Fixes
https://linear.app/chatwoot/issue/CW-6091/editor-breaks-when-inserting-canned-response

## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/9a5215dfef2949fcaa3871f51bdec4bb


## 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
2025-12-09 09:15:35 +05:30
Vinay KeerthiandGitHub 141dfc3321 fix: preserve newlines and formatting in Twilio WhatsApp messages (#13022)
## Description

This PR fixes an issue where Twilio WhatsApp messages were losing
newlines and markdown formatting. The problem had two root causes:

1. Text-based renderers (WhatsApp, Instagram, SMS) were converting
newlines to spaces when processing plain text without markdown list
markers
2. Twilio WhatsApp channels were incorrectly using the plain text
renderer instead of the WhatsApp renderer, stripping all markdown
formatting

The fix updates the markdown rendering system to:
- Preserve newlines by overriding the `softbreak` method in WhatsApp,
Instagram, and PlainText renderers
- Detect Twilio WhatsApp channels (via the `medium` field) and route
them to use the WhatsApp renderer
- Maintain backward compatibility with existing code

## Type of change

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

Added comprehensive test coverage:
- 3 new tests for newline preservation in WhatsApp, Instagram, and SMS
channels
- 4 new tests for Twilio WhatsApp specific behavior (medium detection,
formatting preservation, backward compatibility)
- All 53 tests passing (up from 50)

Manual testing verified:
- Twilio WhatsApp messages with plain text preserve newlines
- Twilio WhatsApp messages with markdown preserve formatting (bold,
italic, links)
- Regular WhatsApp, Instagram, and SMS channels continue to work
correctly
- Backward compatibility maintained when channel parameter is not
provided

## 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
2025-12-08 22:04:30 +05:30
aa21c15d0e fix: hide linear card when not enabled (#12918)
The Linear card now only appears in conversation sidebar when:

- The linear_integration feature flag is enabled for the account
- LINEAR_CLIENT_ID is configured (inferred from the integration existing
in the store)

This matches the backend behavior: if LINEAR_CLIENT_ID is not set, the
integration is filtered out of the API response, so it won't exist in
the store.

In addition, I discovered that Settings/Integrations page showed Linear
card even if it was disabled but Linear client_id set. Now the Linear
card shows only if both conditions are met.

Fixes #12909 

## How Has This Been Tested?

#### Before


https://github.com/user-attachments/assets/cd21b881-5332-48f8-b230-662abc256ba2


#### After



https://github.com/user-attachments/assets/d794cc2e-19d6-4545-b2ef-3af054c2ac81



---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-08 20:49:50 +05:30
Tanmay Deep SharmaandGitHub 3051da1e44 fix: add renewal identification for credit flow (#12999) 2025-12-08 18:35:33 +05:30
399c91adaa feat: Standardize rich editor across all channels (#12600)
# Pull Request Template

## Description

This PR includes,

1. **Channel-specific formatting and menu options** for the rich reply
editor.
2. **Removal of the plain reply editor** and full **standardization** on
the rich reply editor across all channels.
3. **Fix for multiple canned responses insertion:**
* **Before:** The plain editor only allowed inserting canned responses
at the beginning of a message, making it impossible to combine multiple
canned responses in a single reply. This caused inconsistent behavior
across the app.
* **Solution:** Replaced the plain reply editor with the rich
(ProseMirror) editor to ensure a unified experience. Agents can now
insert multiple canned responses at any cursor position.
4. **Floating editor menu** for the reply box to improve accessibility
and overall user experience.
5. **New Strikethrough formatting option** added to the editor menu.

---

**Editor repo PR**:
https://github.com/chatwoot/prosemirror-schema/pull/36

Fixes https://github.com/chatwoot/chatwoot/issues/12517,
[CW-5924](https://linear.app/chatwoot/issue/CW-5924/standardize-the-editor),
[CW-5679](https://linear.app/chatwoot/issue/CW-5679/allow-inserting-multiple-canned-responses-in-a-single-message)

## Type of change

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

## How Has This Been Tested?

### Screenshot
**Dark**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/47748e6c-380f-44a3-9e3b-c27e0c830bd0"
/>

**Light**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/6746cf32-bf63-4280-a5bd-bbd42c3cbe84"
/>


## 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>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2025-12-08 14:43:45 +05:30
eb759255d8 perf: update the logic to purchase credits (#12998)
## Description

- Replaces Stripe Checkout session flow with direct card charging for AI
credit top-ups
- Adds a two-step confirmation modal (select package → confirm purchase)
for better UX
- Creates Stripe invoice directly and charges the customer's default
payment method immediately

## Type of change

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

## How Has This Been Tested?

- Using the specs
- UI manual test cases

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/52bdad46-cd0e-4927-b13f-54c6b6353bcc"
/>

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/231bc7e9-41ac-440d-a93d-cba45a4d3e3e"
/>


## 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: Shivam Mishra <scm.mymail@gmail.com>
2025-12-08 10:52:17 +05:30
Sojan JoseandGitHub cc86b8c7f1 fix: stream attachment handling in workers (#12870)
We’ve been watching Sidekiq workers climb from ~600 MB at boot to
1.4–1.5 GB after an hour whenever attachment-heavy jobs run. This PR is
an experiment to curb that growth by streaming attachments instead of
loading the whole blob into Ruby: reply-mailer inline attachments,
Telegram uploads, and audio transcriptions now read/write in chunks. If
this keeps RSS stable in production we’ll keep it; otherwise we’ll roll
it back and keep digging
2025-12-05 13:02:53 -08:00
a971ff00f8 fix: ruby_llm version conflicts with ai-agents (#13011)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
2025-12-05 10:52:13 +05:30
67dc21ea5f fix: Hardcoded 500 in AI api error response(#13005)
## 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 false new relic alerts set due to hardcoding an error code

## Type of change


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="776" height="666" alt="image"
src="https://github.com/user-attachments/assets/f086890d-eaf1-4e83-b383-fe3675b24159"
/>

the 500 was hardcoded. 
RubyLLM doesn't send any error codes, so i removed the error code
argument and just pass the error message

Langfuse gets just the error message

<img width="883" height="700" alt="image"
src="https://github.com/user-attachments/assets/fc8c3907-b9a5-4c87-bfc6-8e05cfe9c8b0"
/>

local logs only show error
<img width="1434" height="200" alt="image"
src="https://github.com/user-attachments/assets/716c6371-78f0-47b8-88a4-03e4196c0e9a"
/>

Better fix is to handle each case and show the user wherever necessary

## 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: aakashb95 <aakash@chatwoot.com>
2025-12-04 20:32:46 +05:30
Muhsin KelothandGitHub efc3b5e7d4 fix: Handle Instagram API error codes properly in message processing (#13002)
### Problem

Instagram webhook processing was failing with:

> TypeError: no implicit conversion of String into Integer

This occurred when handling **echo messages** (outgoing messages) that:

* Contained `unsupported_type` attachments, and
* Were sent to a recipient user that could **not** be fetched via the
Instagram API.

In these cases, the webhook job crashed while trying to create or find
the contact for the recipient.

### Root Cause

The Instagram message service does not correctly handle Instagram API
error code **100**:

> "Object with ID does not exist, cannot be loaded due to missing
permissions, or does not support this operation"

When this error occurred during `fetch_instagram_user`:

1. The method fell through to exception tracking without an explicit
return.
2. `ChatwootExceptionTracker.capture_exception` returned `true`.
3. As a result, `fetch_instagram_user` effectively returned `true`
instead of a hash or empty hash.
4. `ensure_contact` then called `find_or_create_contact(true)` because
`true.present?` is `true`.
5. `find_or_create_contact` crashed when it tried to access
`true['id']`.

So the chain was:

```txt
fetch_instagram_user -> returns true
ensure_contact -> find_or_create_contact(true)
find_or_create_contact -> true['id'] -> TypeError
```

**Example Webhook Payload**

```
{
  "object": "instagram",
  "entry": [{
    "time": 1764822592663,
    "id": "17841454414819988",
    "messaging": [{
      "sender": { "id": "17841454414819988" },     // Business account
      "recipient": { "id": "1170166904857608" },   // User that can't be fetched
      "timestamp": 1764822591874,
      "message": {
        "attachments": [{
          "type": "unsupported_type",
          "payload": { "url": "https://..." }
        }],
        "is_echo": true
      }
    }]
  }]
}
```

**Corresponding Instagram API error:**

```
{
  "error": {
    "message": "The requested user cannot be found.",
    "type": "IGApiException",
    "code": 100,
    "error_subcode": 2534014
  }
}
```

**Debug Logs (Before Fix)**

```
[InstagramUserFetchError]: Unsupported get request. Object with ID '17841454414819988' does not exist... 100
[DEBUG] result: true
[DEBUG] result.present?: true
[DEBUG] find_or_create_contact called
[DEBUG] user: true
[DEBUG] Invalid user parameter - expected hash with id, got TrueClass: true
```

### Solution

### 1\. Handle Error Code 100 Explicitly

We now treat Instagram API error code **100** as a valid case for
creating an “unknown” contact, similar to how we already handle error
code `9010`:

```
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist
# or has privacy restrictions. We can safely create an unknown contact.
return unknown_user(ig_scope_id) if error_code == 100
```

This ensures:

* `fetch_instagram_user` returns a valid hash for unknown users.
* `ensure_contact` can proceed safely without crashing.

### 2\. Prevent Exception Tracker Results from Leaking Through

For any **unhandled** error codes, we now explicitly return an empty
hash after logging the exception:

```
exception = StandardError.new(
  "#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})"
)
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception

# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result (true/false) from being returned.
{}
```

This guarantees that `fetch_instagram_user` always returns either:

* A valid user hash,
* An “unknown” user hash
* An empty hash
Fixes
https://linear.app/chatwoot/issue/CW-6068/typeerror-no-implicit-conversion-of-string-into-integer-typeerror
2025-12-04 18:53:50 +05:30
eed2eaceb0 feat: Migrate ruby llm captain (#12981)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-04 18:26:10 +05:30
Muhsin KelothandGitHub 0a17976913 fix: Filter out unsupported ephemeral message attachments (#13003)
Fixes
https://linear.app/chatwoot/issue/CW-6070/argumenterror-ephemeral-is-not-a-valid-file-type-argumenterror

**Problem**
The instagram webhooks containing ephemeral (disappearing) message were
causing ArgumentError exceptions because this attachment type is not
supported and was not in the enum validation.

**Solution**
- Added ephemeral to the unsupported_file_type? filter
- Ephemeral attachments are now silently filtered out before processing,
following the same pattern as existing unsupported types (template,
unsupported_type)
2025-12-04 16:09:04 +05:30
57904a56a0 chore: update vulnerable packages (#12996)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-12-04 15:28:12 +05:30
Vishnu NarayananandGitHub 728a5a6710 fix: handle missing AccountUser in inbox_member API (#12993)
Fixes
https://linear.app/chatwoot/issue/CW-6065/actionviewtemplateerror-undefined-method-availability-status-for-nil
2025-12-04 13:32:51 +05:30
87fe1e9ad7 feat: migrate editor to ruby-llm (#12961)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-04 12:51:35 +05:30
Muhsin KelothandGitHub 5c3b85334b feat: Add support for shared post and story attachment types in Instagram messages (#12997)
When users share Instagram posts or stories via DM, Instagram sends
webhooks with type `ig_post` and `ig_story` attachments. The system was
failing on these types because they weren't defined in the file_types.
This PR fixes the issue by handling all shared types and rendering them
on the front end.

**Shared post**

<img width="2154" height="1828" alt="CleanShot 2025-12-03 at 16 29
14@2x"
src="https://github.com/user-attachments/assets/7e731171-4904-43a6-abeb-b1db2c262742"
/>

**Shared status**
<img width="1702" height="1676" alt="CleanShot 2025-12-03 at 16 10
25@2x"
src="https://github.com/user-attachments/assets/6a151233-ce47-429d-b7c2-061514b20e05"
/>


Fixes
https://linear.app/chatwoot/issue/CW-5441/argumenterror-ig-story-is-not-a-valid-file-type-argumenterror
2025-12-04 05:20:47 +05:30
Muhsin KelothandGitHub e6a7e836a0 fix: Add support for ig_post attachment type in Instagram messages (#12992)
Fixes
https://linear.app/chatwoot/issue/CW-6055/argumenterror-ig-post-is-not-a-valid-file-type-argumenterror

This PR fixes an issue where Instagram sends webhooks with both "type":
"share" and "type": "ig_post" attachments when users share Instagram
posts in direct messages. The system was failing on the ig_post type
because it wasn't defined, causing ArgumentError exceptions.


https://github.com/user-attachments/assets/577b8ebd-80e3-4c11-95f5-d8a8c3e16534
2025-12-03 10:27:16 +05:30
b269cca0bf feat: Add AI credit topup flow for Stripe (#12988)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-12-02 17:53:44 -08:00
213 changed files with 7862 additions and 6452 deletions
+1 -1
View File
@@ -1 +1 @@
20.5.1
23.7.0
+8 -1
View File
@@ -7,6 +7,7 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
@@ -41,6 +42,12 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -88,7 +95,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
+4 -3
View File
@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe', '17.2.0.pre.alpha.2'
gem 'stripe', '~> 18.0'
## - helper gems --##
## to populate db with sample data
@@ -191,9 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
@@ -214,7 +215,7 @@ group :production do
end
group :development do
gem 'annotate'
gem 'annotaterb'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
+13 -11
View File
@@ -126,11 +126,11 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -819,7 +819,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.5.1)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -827,8 +827,9 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -928,7 +929,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (17.2.0.pre.alpha.2)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1017,8 +1018,8 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
annotate
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
@@ -1119,6 +1120,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1139,7 +1141,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe (= 17.2.0.pre.alpha.2)
stripe (~> 18.0)
telephone_number
test-prof
tidewave
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
external_url: url,
remote_file_url: url
}
end
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end
@@ -0,0 +1,160 @@
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
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
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)
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
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
def extract_template_params
params.require(:template).permit(:message, :button_text, :language)
end
def render_missing_message_error
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)
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
end
def render_failed_template_creation(result)
whatsapp_error = parse_whatsapp_error(result[:response_body])
error_message = whatsapp_error[:user_message] || result[:error]
render json: {
error: error_message,
details: whatsapp_error[:technical_details]
}, 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?
begin
error_data = JSON.parse(response_body)
whatsapp_error = error_data['error'] || {}
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
technical_details = {
code: whatsapp_error['code'],
subcode: whatsapp_error['error_subcode'],
type: whatsapp_error['type'],
title: whatsapp_error['error_user_title']
}.compact
{ user_message: user_message, technical_details: technical_details }
rescue JSON::ParserError
{ user_message: nil, technical_details: response_body }
end
end
end
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
formatted = {
'display_type' => config['display_type'] || 'emoji',
'message' => config['message'] || '',
:survey_rules => {
'operator' => config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || []
},
'button_text' => config['button_text'] || 'Please rate us',
'language' => config['language'] || 'en'
}
format_template_config(config, formatted)
formatted
end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
)
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
end
def channel_type_from_params
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
+1 -4
View File
@@ -8,10 +8,7 @@ module BillingHelper
# Return false if not plans are configured, so that no checks are enforced
return false if default_plan.blank?
# Handle both string and hash formats for default_plan
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
end
def conversations_this_month(account)
@@ -6,7 +6,6 @@ class EnterpriseAccountAPI extends ApiClient {
super('', { accountScoped: true, enterprise: true });
}
// V1 endpoints
checkout() {
return axios.post(`${this.url}checkout`);
}
@@ -25,47 +24,8 @@ class EnterpriseAccountAPI extends ApiClient {
});
}
// V2 Billing endpoints
get v2BillingUrl() {
const accountId = this.accountIdFromRoute;
return `/enterprise/api/v2/accounts/${accountId}/billing`;
}
getCreditGrants() {
return axios.get(`${this.v2BillingUrl}/credit_grants`);
}
getPricingPlans() {
return axios.get(`${this.v2BillingUrl}/pricing_plans`);
}
getTopupOptions() {
return axios.get(`${this.v2BillingUrl}/topup_options`);
}
topupCredits(credits) {
return axios.post(`${this.v2BillingUrl}/topup`, { credits });
}
subscribeToPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/subscribe`, {
pricing_plan_id: pricingPlanId,
quantity,
});
}
cancelSubscription(reason = null, feedback = null) {
return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
reason,
feedback,
});
}
changePricingPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
pricing_plan_id: pricingPlanId,
quantity,
});
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});
@@ -19,12 +19,12 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -102,12 +102,12 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -139,19 +139,6 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
@@ -50,12 +50,6 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
@@ -80,12 +74,20 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
const setSignature = () => {
if (signatureToApply.value) {
if (props.messageSignature) {
if (sendWithSignature.value) {
emit('addSignature', signatureToApply.value);
emit('addSignature', props.messageSignature);
} else {
emit('removeSignature', signatureToApply.value);
emit('removeSignature', props.messageSignature);
}
}
};
@@ -101,7 +103,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
if (newValue && !isVoiceInbox.value) setSignature();
});
},
{ immediate: true }
@@ -220,7 +222,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && isRegularMessageMode"
v-if="shouldShowSignatureButton"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -39,7 +39,7 @@ const removeAttachment = id => {
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
extractTextFromMarkdown,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@@ -194,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
@@ -202,25 +209,28 @@ const handleInboxAction = ({ value, action, ...rest }) => {
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
@@ -228,11 +238,19 @@ const onClickInsertEmoji = emoji => {
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => {
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
@@ -1,127 +1,49 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
: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="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
<Editor
:key="editorKey"
v-model="modelValue"
: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="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
</div>
</template>
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
return DyteBubble;
}
if (props.contentAttributes.imageType === 'story_mention') {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
return InstagramStoryBubble;
}
@@ -476,7 +481,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full message-bubble-container mb-2"
class="flex mb-2 w-full message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -49,6 +49,8 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
export const CONTENT_TYPES = {
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -53,6 +51,10 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -75,12 +77,12 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -103,22 +105,40 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
);
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema: messageSchema,
schema,
placeholder,
methods,
plugins,
enabledMenuOptions,
enabledMenuOptions: editorMenuOptions.value,
}),
});
};
@@ -153,6 +173,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -174,12 +196,6 @@ const shouldShowCannedResponses = computed(() => {
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -293,8 +309,13 @@ function isBodyEmpty(content) {
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(content, props.signature)
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
: content;
// trimming should remove all the whitespaces, so we can check the length
@@ -362,7 +383,11 @@ function addSignature() {
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(content, props.signature);
content = appendSignature(
content,
props.signature,
effectiveChannelType.value
);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -374,7 +399,11 @@ function addSignature() {
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(content, props.signature);
content = removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
@@ -400,6 +429,38 @@ function setToolbarPosition() {
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -529,7 +590,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -596,6 +659,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -761,15 +825,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col;
@apply flex flex-col gap-3;
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
.ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3;
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
}
> .ProseMirror {
@@ -860,4 +942,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>
@@ -78,10 +78,6 @@ export default {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -130,7 +126,6 @@ export default {
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -325,18 +320,8 @@ export default {
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
@@ -45,7 +45,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
@@ -61,7 +61,6 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -69,11 +68,12 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -86,7 +86,6 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -97,7 +96,6 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -115,7 +113,6 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
@@ -147,9 +144,12 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
const senderId = this.currentChat?.meta?.sender?.id;
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
@@ -159,20 +159,6 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
@@ -300,9 +286,6 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -342,21 +325,11 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.signatureToApply;
return !!this.messageSignature;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -383,12 +356,6 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -439,6 +406,19 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -512,7 +492,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
// working even if the editor is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -566,28 +546,6 @@ export default {
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -653,7 +611,23 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
@@ -716,7 +690,7 @@ export default {
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
@@ -851,7 +825,19 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
message = appendSignature(message, this.signatureToApply);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
}
const updatedMessage = replaceVariablesInMessage({
@@ -908,7 +894,19 @@ export default {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -926,19 +924,15 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
if (!this.$refs.audioRecorderInput) return;
if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) {
} else {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -1245,16 +1239,17 @@ export default {
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="signatureToApply"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1302,7 +1297,6 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1315,7 +1309,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,13 +112,13 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
@@ -132,8 +132,8 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,6 +9,7 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -34,6 +35,8 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -46,5 +49,6 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}
+217 -25
View File
@@ -1,23 +1,143 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
// Formatting rules for different contexts (channels and special contexts)
// marks: inline formatting (strong, em, code, link, strike)
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: ['image'],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -33,14 +153,86 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
* Maps camelCase type names to ProseMirror snake_case schema names.
* Order matters: codeBlock before code to avoid partial matches.
*/
export const MARKDOWN_PATTERNS = [
// --- BLOCK NODES ---
{
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
patterns: [
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
],
},
{
type: 'blockquote', // PM: blockquote, eg: > quote
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
},
{
type: 'bulletList', // PM: bullet_list, eg: - item
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
},
{
type: 'orderedList', // PM: ordered_list, eg: 1. item
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
},
{
type: 'heading', // PM: heading, eg: ## Heading
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
},
{
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
},
{
type: 'image', // PM: image, eg: ![alt](url)
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
},
{
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
patterns: [
{ pattern: /\\\n/g, replacement: '\n' },
{ pattern: / {2,}\n/g, replacement: '\n' },
],
},
// --- INLINE MARKS ---
{
type: 'strong', // PM: strong, eg: **bold** or __bold__
patterns: [
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
{ pattern: /__(.+?)__/g, replacement: '$1' },
],
},
{
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
{
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
replacement: '$1',
},
],
},
{
type: 'strike', // PM: strike, eg: ~~strikethrough~~
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
},
{
type: 'code', // PM: code, eg: `inline code`
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
+258 -30
View File
@@ -5,6 +5,82 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -67,15 +143,39 @@ export function findSignatureInBody(body, signature) {
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
@@ -86,16 +186,34 @@ export function appendSignature(body, signature) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
channelStripped,
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
@@ -136,28 +254,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*
@@ -283,6 +379,47 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
@@ -313,8 +450,17 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +535,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */
+5
View File
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -1,15 +1,11 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
const { node } = getContentNode(
replaceVariablesInMessage.mockReturnValue(updatedMessage);
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
editorView,
'cannedResponse',
content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
});
});
@@ -5,11 +5,18 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -138,6 +145,107 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
@@ -196,6 +304,37 @@ describe('removeSignature', () => {
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
@@ -258,15 +397,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, docNode, 0);
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +761,349 @@ describe('getContentNode', () => {
});
});
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('stripUnsupportedFormatting', () => {
describe('when schema supports all formatting', () => {
const fullSchema = {
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
};
it('preserves all formatting when schema supports it', () => {
const content = '**bold** and *italic* and `code`';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves links when schema supports them', () => {
const content = 'Check [this link](https://example.com)';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
});
describe('when schema has no formatting support (eg:SMS channel)', () => {
const emptySchema = {
marks: {},
nodes: {},
};
it('strips bold formatting', () => {
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
'bold text'
);
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
'bold text'
);
});
it('strips italic formatting', () => {
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
'italic text'
);
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
'italic text'
);
});
it('preserves underscores in URLs and mid-word positions', () => {
// Underscores in URLs should not be stripped as italic formatting
expect(
stripUnsupportedFormatting(
'https://www.chatwoot.com/new_first_second-third/ssd',
emptySchema
)
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
// Underscores in variable names should not be stripped
expect(
stripUnsupportedFormatting('some_variable_name', emptySchema)
).toBe('some_variable_name');
// But actual italic formatting with spaces should still be stripped
expect(
stripUnsupportedFormatting('hello _world_ there', emptySchema)
).toBe('hello world there');
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
);
});
it('strips strikethrough formatting', () => {
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
'strikethrough'
);
});
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
).toBe('item 1\nitem 2');
expect(
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
).toBe('item 1\nitem 2');
});
it('strips ordered list markers', () => {
expect(
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
).toBe('first\nsecond');
});
it('strips code block markers', () => {
expect(
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
).toBe('code here\n');
});
it('strips blockquote markers', () => {
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
'quoted text'
);
});
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
describe('when schema has partial support', () => {
const partialSchema = {
marks: { strong: {}, em: {} },
nodes: {},
};
it('preserves supported marks and strips unsupported ones', () => {
const content = '**bold** and `code`';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** and code'
);
});
it('strips unsupported nodes but keeps supported marks', () => {
const content = '**bold** text\n- list item';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** text\nlist item'
);
});
});
describe('edge cases', () => {
it('returns content unchanged if content is empty', () => {
expect(stripUnsupportedFormatting('', {})).toBe('');
});
it('returns content unchanged if content is null', () => {
expect(stripUnsupportedFormatting(null, {})).toBe(null);
});
it('returns content unchanged if content is undefined', () => {
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
});
it('returns content unchanged if schema is null', () => {
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
});
it('handles nested formatting correctly', () => {
const emptySchema = { marks: {}, nodes: {} };
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
expect(
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
).toBe('bold and italic');
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});
@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
@@ -145,7 +145,11 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -382,8 +382,6 @@
"BILLING_SETTINGS": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"BILLING_PORTAL": "Billing Portal",
"VIEW_ALL_PLANS": "View all plans",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
@@ -396,111 +394,44 @@
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"SUBSCRIPTION": {
"TITLE": "Subscription",
"DESCRIPTION": "Manage your current plan, seats, and billing cycle.",
"CURRENT_PLAN": "Current plan",
"NUMBER_OF_SEATS": "Number of seats",
"RENEWS_ON": "Renews on",
"ENDS_ON": "Ends on",
"CANCELLED_ON": "Cancelled on",
"CHANGE_PLAN": "Change",
"CHANGE_SEATS": "Change",
"CANCEL_SUBSCRIPTION": "Cancel"
},
"CAPTAIN_AI": {
"TITLE": "Captain AI",
"DESCRIPTION": "Purchase and manage credits for Captain AI features.",
"AI_CREDITS": "AI credits",
"PURCHASE_CREDITS": "Purchase credits",
"VIEW_HISTORY": "View credit history"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHANGE_PLAN_MODAL": {
"TITLE": "Change plan",
"DESCRIPTION": "Select a plan that best fits your needs.",
"SUBSCRIBE_TITLE": "Choose a plan",
"SUBSCRIBE_DESCRIPTION": "Select a plan to subscribe and unlock all features.",
"CURRENT_PLAN": "Current plan",
"CREDITS_MONTH": "{credits} credits/month",
"PER_USER_MONTH": "/user/month",
"AI_CREDITS_MONTH": "AI credits/month",
"CANCEL": "Cancel",
"SELECT_PLAN": "Select plan",
"CHANGE_PLAN": "Change plan",
"SUBSCRIBE": "Subscribe"
},
"CANCEL_MODAL": {
"TITLE": "Cancel subscription",
"WARNING_TITLE": "You're about to cancel your {plan} plan",
"WARNING_DESCRIPTION": "Your subscription will remain active until {date}. After that, you'll lose access to:",
"FEATURE_AI_CREDITS": "AI credits for Captain features",
"FEATURE_TEAM_COLLABORATION": "Team collaboration tools",
"FEATURE_PRIORITY_SUPPORT": "Priority customer support",
"KEEP_SUBSCRIPTION": "Keep subscription",
"CANCEL_SUBSCRIPTION": "Cancel subscription"
},
"FEEDBACK_MODAL": {
"TITLE": "Help us improve",
"DESCRIPTION": "We're sorry to see you go. Please help us improve by sharing your reason for cancelling.",
"REASON_LABEL": "Why are you cancelling?",
"REASONS": {
"TOO_EXPENSIVE": "It's too expensive",
"NOT_USING": "I'm not using it enough",
"MISSING_FEATURES": "Missing features I need",
"BETTER_ALTERNATIVE": "Found a better alternative",
"TECHNICAL_ISSUES": "Technical issues",
"OTHER": "Other reason"
},
"FEEDBACK_LABEL": "Additional feedback (optional)",
"FEEDBACK_PLACEHOLDER": "Tell us more about your experience...",
"GO_BACK": "Go back",
"SUBMIT_CANCEL": "Submit and cancel"
},
"PURCHASE_MODAL": {
"TITLE": "Purchase AI credits",
"DESCRIPTION": "Choose a credit package that suits your needs.",
"MOST_POPULAR": "Most popular",
"CREDITS": "credits",
"NOTE": "Credits will be added to your account immediately and are non-refundable. Unused credits expire after 12 months.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase"
},
"CREDIT_HISTORY": {
"TITLE": "Credit history",
"DESCRIPTION": "View your credit purchases and allocations.",
"NAME": "Name",
"CREDITS": "Credits",
"SOURCE": "Source",
"EFFECTIVE_AT": "Effective at",
"EXPIRES_AT": "Expires at",
"NO_RECORDS": "No credit history found.",
"CLOSE": "Close"
},
"HELP": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"CHAT": "Chat with us"
},
"ALERTS": {
"PLAN_CHANGED": "Your plan has been changed successfully.",
"SEATS_UPDATED": "Number of seats updated successfully.",
"SUBSCRIPTION_CANCELLED": "Your subscription has been scheduled for cancellation.",
"CREDITS_PURCHASED": "Credits purchased successfully."
"RESPONSES": "Credits",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
@@ -5,7 +5,9 @@ import {
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
@@ -52,12 +54,22 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const { isCloudFeatureEnabled } = useAccount();
const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearIntegrationEnabled = computed(
const isLinearClientIdConfigured = computed(() => {
return !!linearIntegration.value?.id;
});
const isLinearConnected = computed(
() => linearIntegration.value?.enabled || false
);
@@ -238,7 +250,13 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div v-else-if="element.name === 'linear_issues'">
<div
v-else-if="
element.name === 'linear_issues' &&
isLinearFeatureEnabled &&
isLinearClientIdConfigured
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
@@ -247,7 +265,7 @@ onMounted(() => {
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
<LinearSetupCTA v-if="!isLinearConnected" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
@@ -1,252 +1,140 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAlert } from 'dashboard/composables';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import SubscriptionRow from './components/SubscriptionRow.vue';
import SeatStepper from './components/SeatStepper.vue';
import ChangePlanModal from './components/ChangePlanModal.vue';
import CancelSubscriptionModal from './components/CancelSubscriptionModal.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import CreditHistoryModal from './components/CreditHistoryModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonV4 from 'next/button/Button.vue';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits: fetchCaptainLimits,
fetchLimits,
isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
const pricingPlans = useMapGetter('accounts/getPricingPlans');
const topupOptions = useMapGetter('accounts/getTopupOptions');
const creditGrants = useMapGetter('accounts/getCreditGrants');
const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// Modal refs
const changePlanModalRef = ref(null);
const cancelModalRef = ref(null);
const purchaseCreditsModalRef = ref(null);
const creditHistoryModalRef = ref(null);
// State
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const isEditingSeats = ref(false);
const editedSeats = ref(0);
const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
const planName = computed(() => customAttributes.value.plan_name);
const currentPlanId = computed(
() => customAttributes.value.stripe_pricing_plan_id
);
const subscriptionId = computed(
() => customAttributes.value.stripe_subscription_id
);
const subscribedQuantity = computed(
() => customAttributes.value.subscribed_quantity
);
const subscriptionStatus = computed(
() => customAttributes.value.subscription_status
);
/**
* Computed property for plan name
* @returns {string|undefined}
*/
const planName = computed(() => {
return customAttributes.value.plan_name;
});
// Check if user has an active subscription (required for seat changes and cancellation)
const hasActiveSubscription = computed(() => !!subscriptionId.value);
const canPurchaseCredits = computed(() => {
const plan = planName.value?.toLowerCase();
return plan && plan !== 'hacker';
});
const subscriptionEndsAt = computed(() => {
if (!customAttributes.value.subscription_ends_at) return '';
const endDate = new Date(customAttributes.value.subscription_ends_at);
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
*/
const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
// return date as 12 Jan, 2034
return format(endDate, 'dd MMM, yyyy');
});
const subscriptionCancelledAt = computed(() => {
if (!customAttributes.value.subscription_cancelled_at) return '';
const cancelledDate = new Date(
customAttributes.value.subscription_cancelled_at
);
return format(cancelledDate, 'dd MMM, yyyy');
/**
* Computed property indicating if user has a billing plan
* @returns {boolean}
*/
const hasABillingPlan = computed(() => {
return !!planName.value;
});
const hasABillingPlan = computed(() => !!planName.value);
const isCancelled = computed(() => {
return subscriptionStatus.value === 'cancel_at_period_end';
});
// Captain credits
const creditsUsed = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.consumed || 0;
});
const creditsTotal = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.totalCount || 0;
});
// Actions
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
}
// Always fetch captain limits to show AI credits
fetchCaptainLimits();
// Always fetch limits for billing page to show credit usage
fetchLimits();
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const openBillingPortal = () => {
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
const openChatWidget = () => {
const onToggleChatWindow = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
// Modal handlers
const openChangePlanModal = async () => {
await store.dispatch('accounts/fetchPricingPlans');
changePlanModalRef.value?.open();
};
const handlePlanSelect = async plan => {
try {
// If no active subscription, use subscribe API (for Hacker plan users)
if (!hasActiveSubscription.value) {
await store.dispatch('accounts/subscribeToPlan', {
pricingPlanId: plan.id,
quantity: 1,
});
// subscribeToPlan redirects to Stripe, so no need to close modal
return;
}
// Otherwise use change plan API (for existing subscribers)
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: plan.id,
quantity: subscribedQuantity.value || 1,
});
changePlanModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.PLAN_CHANGED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Seats editing
const startEditingSeats = () => {
editedSeats.value = subscribedQuantity.value || 1;
isEditingSeats.value = true;
};
const cancelEditingSeats = () => {
isEditingSeats.value = false;
};
const saveSeats = async newSeats => {
if (newSeats === subscribedQuantity.value) {
isEditingSeats.value = false;
return;
}
try {
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: currentPlanId.value,
quantity: newSeats,
});
isEditingSeats.value = false;
useAlert(t('BILLING_SETTINGS.ALERTS.SEATS_UPDATED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Cancel subscription
const openCancelModal = () => {
cancelModalRef.value?.open();
};
const handleCancelConfirm = async ({ reason, feedback }) => {
try {
await store.dispatch('accounts/cancelAccountSubscription', {
reason,
feedback,
});
cancelModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.SUBSCRIPTION_CANCELLED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Purchase credits
const openPurchaseCreditsModal = async () => {
await store.dispatch('accounts/fetchTopupOptions');
const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
const handlePurchaseCredits = async option => {
try {
await store.dispatch('accounts/purchaseCredits', {
credits: option.credits,
});
purchaseCreditsModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.CREDITS_PURCHASED'));
fetchCaptainLimits();
} catch {
// Error already handled
}
};
// Credit history
const openCreditHistoryModal = async () => {
await store.dispatch('accounts/fetchCreditGrants');
creditHistoryModalRef.value?.open();
const handleTopupSuccess = () => {
// Refresh limits to show updated credit balance
fetchLimits();
};
onMounted(handleBillingPageLogic);
@@ -267,187 +155,113 @@ onMounted(handleBillingPageLogic);
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS.TITLE')"
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
>
<template #actions>
<Button
solid
blue
sm
:label="$t('BILLING_SETTINGS.BILLING_PORTAL')"
:is-loading="uiFlags.isCheckoutInProcess"
@click="openBillingPortal"
/>
</template>
</BaseSettingsHeader>
/>
</template>
<template #body>
<section class="flex flex-col gap-6">
<a
href="https://www.chatwoot.com/pricing"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-n-blue-text hover:underline"
>
{{ $t('BILLING_SETTINGS.VIEW_ALL_PLANS') }}
<span class="i-lucide-chevron-right size-4" />
</a>
<!-- Subscription Card -->
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.SUBSCRIPTION.DESCRIPTION')"
>
<div class="px-5">
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CURRENT_PLAN')"
:value="planName"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_PLAN')"
:action-disabled="isCancelled"
@action="openChangePlanModal"
/>
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.NUMBER_OF_SEATS')"
:value="subscribedQuantity"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_SEATS')"
:show-action="
!isEditingSeats && !isCancelled && hasActiveSubscription
"
@action="startEditingSeats"
>
<template v-if="isEditingSeats" #value>
<SeatStepper
v-model="editedSeats"
:min="1"
:is-loading="uiFlags.isChangingPlan"
@save="saveSeats"
@cancel="cancelEditingSeats"
/>
</template>
</SubscriptionRow>
<SubscriptionRow
v-if="isCancelled"
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CANCELLED_ON')"
:value="subscriptionCancelledAt"
/>
<SubscriptionRow
v-if="hasActiveSubscription"
:label="
isCancelled
? $t('BILLING_SETTINGS.SUBSCRIPTION.ENDS_ON')
: $t('BILLING_SETTINGS.SUBSCRIPTION.RENEWS_ON')
"
:value="subscriptionEndsAt"
:action-text="
isCancelled
? ''
: $t('BILLING_SETTINGS.SUBSCRIPTION.CANCEL_SUBSCRIPTION')
"
@action="openCancelModal"
/>
</div>
</BillingCard>
<!-- Captain AI Card -->
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN_AI.DESCRIPTION')"
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
>
<template #action>
<Button
solid
slate
sm
:label="$t('BILLING_SETTINGS.CAPTAIN_AI.PURCHASE_CREDITS')"
@click="openPurchaseCreditsModal"
/>
<ButtonV4 sm solid blue @click="onClickBillingPortal">
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
</ButtonV4>
</template>
<div class="px-5">
<div
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
>
<DetailItem
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
:value="planName"
/>
<DetailItem
v-if="subscribedQuantity"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
:value="subscribedQuantity"
/>
<DetailItem
v-if="subscriptionRenewsOn"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
</div>
</BillingCard>
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
sm
flushed
slate
icon="i-lucide-refresh-cw"
:is-loading="isFetchingLimits"
@click="fetchLimits"
>
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
</ButtonV4>
<ButtonV4
v-if="canPurchaseCredits"
sm
solid
blue
@click="openPurchaseCreditsModal"
>
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
</ButtonV4>
</div>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.AI_CREDITS')"
:consumed="creditsUsed"
:total-count="creditsTotal"
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
v-bind="responseLimits"
/>
</div>
<div v-if="captainLimits && documentLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
v-bind="documentLimits"
/>
</div>
<div class="px-5 pt-2">
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click="openCreditHistoryModal"
>
{{ $t('BILLING_SETTINGS.CAPTAIN_AI.VIEW_HISTORY') }}
</button>
</div>
</BillingCard>
<!-- Captain AI Upgrade Card (when not enabled) -->
<BillingCard
v-else
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
>
<template #action>
<Button
solid
slate
sm
:label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@click="openBillingPortal"
/>
<ButtonV4 sm solid slate @click="onClickBillingPortal">
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
</ButtonV4>
</template>
</BillingCard>
<!-- Help Section -->
<BillingHeader
class="px-1 mt-2"
:title="$t('BILLING_SETTINGS.HELP.TITLE')"
:description="$t('BILLING_SETTINGS.HELP.DESCRIPTION')"
class="px-1 mt-5"
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
>
<Button
<ButtonV4
sm
solid
slate
sm
icon="i-lucide-message-circle"
:label="$t('BILLING_SETTINGS.HELP.CHAT')"
@click="openChatWidget"
/>
icon="i-lucide-life-buoy"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
</BillingHeader>
</section>
<!-- Modals -->
<ChangePlanModal
ref="changePlanModalRef"
:plans="pricingPlans"
:current-plan-id="currentPlanId"
:is-loading="uiFlags.isChangingPlan || uiFlags.isSubscribing"
:is-subscribe-mode="!hasActiveSubscription"
@select="handlePlanSelect"
/>
<CancelSubscriptionModal
ref="cancelModalRef"
:plan-name="planName"
:renews-on="subscriptionEndsAt"
:is-loading="uiFlags.isCancellingSubscription"
@confirm="handleCancelConfirm"
/>
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
:options="topupOptions"
:is-loading="uiFlags.isPurchasingCredits"
@purchase="handlePurchaseCredits"
/>
<CreditHistoryModal
ref="creditHistoryModalRef"
:credit-grants="creditGrants"
:is-loading="uiFlags.isFetchingCreditGrants"
@success="handleTopupSuccess"
/>
</template>
</SettingsLayout>
@@ -1,141 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CancellationFeedbackForm from './CancellationFeedbackForm.vue';
defineProps({
planName: {
type: String,
default: '',
},
renewsOn: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const step = ref('warning'); // 'warning' | 'feedback'
const features = [
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_AI_CREDITS',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_TEAM_COLLABORATION',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_PRIORITY_SUPPORT',
];
const handleProceedToFeedback = () => {
step.value = 'feedback';
};
const handleBack = () => {
step.value = 'warning';
};
const handleFeedbackSubmit = ({ reason, feedback }) => {
emit('confirm', { reason, feedback });
};
const handleClose = () => {
step.value = 'warning';
emit('close');
};
const open = () => {
step.value = 'warning';
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
step === 'warning'
? t('BILLING_SETTINGS.CANCEL_MODAL.TITLE')
: t('BILLING_SETTINGS.FEEDBACK_MODAL.TITLE')
"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Warning Step -->
<template v-if="step === 'warning'">
<div class="p-4 border rounded-lg bg-n-ruby-9/5 border-n-ruby-9/20">
<div class="flex gap-3">
<span
class="flex-shrink-0 i-lucide-alert-triangle size-5 text-n-ruby-9"
/>
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-n-ruby-11">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_TITLE', {
plan: planName,
})
}}
</p>
<p class="text-sm text-n-ruby-11/80">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_DESCRIPTION', {
date: renewsOn,
})
}}
</p>
<ul class="mt-2 space-y-1">
<li
v-for="feature in features"
:key="feature"
class="flex items-center gap-2 text-sm text-n-ruby-11"
>
<span class="size-1.5 rounded-full bg-n-ruby-9" />
{{ t(feature) }}
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-3 mt-6">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CANCEL_MODAL.KEEP_SUBSCRIPTION')"
class="flex-1"
@click="close"
/>
<Button
color="ruby"
solid
:label="t('BILLING_SETTINGS.CANCEL_MODAL.CANCEL_SUBSCRIPTION')"
class="flex-1"
@click="handleProceedToFeedback"
/>
</div>
</template>
<!-- Feedback Step -->
<template v-else>
<CancellationFeedbackForm
:is-loading="isLoading"
@submit="handleFeedbackSubmit"
@back="handleBack"
/>
</template>
</Dialog>
</template>
@@ -1,117 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'back']);
const { t } = useI18n();
const selectedReason = ref('');
const additionalFeedback = ref('');
const cancellationReasons = [
{
key: 'too_expensive',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TOO_EXPENSIVE',
},
{
key: 'not_using',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.NOT_USING',
},
{
key: 'missing_features',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.MISSING_FEATURES',
},
{
key: 'better_alternative',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.BETTER_ALTERNATIVE',
},
{
key: 'technical_issues',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TECHNICAL_ISSUES',
},
{ key: 'other', label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.OTHER' },
];
const handleSubmit = () => {
emit('submit', {
reason: selectedReason.value,
feedback: additionalFeedback.value,
});
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.DESCRIPTION') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.REASON_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<label
v-for="reason in cancellationReasons"
:key="reason.key"
class="flex items-center gap-3 p-3 transition-colors border rounded-lg cursor-pointer border-n-weak hover:bg-n-alpha-1"
:class="{
'border-n-teal-9 bg-n-teal-9/5': selectedReason === reason.key,
}"
>
<input
v-model="selectedReason"
type="radio"
name="cancellation-reason"
:value="reason.key"
class="w-4 h-4 accent-n-teal-9"
/>
<span class="text-sm text-n-slate-12">{{ t(reason.label) }}</span>
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_LABEL') }}
</label>
<textarea
v-model="additionalFeedback"
rows="3"
class="w-full p-3 text-sm transition-colors border rounded-lg resize-none bg-n-alpha-1 border-n-weak text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none focus:border-n-strong"
:placeholder="t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_PLACEHOLDER')"
/>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border rounded-lg border-n-weak text-n-slate-12 hover:bg-n-alpha-1"
:disabled="isLoading"
@click="handleBack"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.GO_BACK') }}
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium text-white transition-colors rounded-lg bg-n-slate-9 hover:bg-n-slate-10 disabled:opacity-50"
:disabled="!selectedReason || isLoading"
@click="handleSubmit"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.SUBMIT_CANCEL') }}
</button>
</div>
</div>
</template>
@@ -1,147 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PlanCard from './PlanCard.vue';
const props = defineProps({
plans: {
type: Array,
default: () => [],
},
currentPlanId: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
isSubscribeMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const selectedPlanId = ref(props.currentPlanId);
const selectedPlan = computed(() => {
return filteredPlans.value.find(p => p.id === selectedPlanId.value);
});
// Filter out Hacker plan when user already has a subscription (change plan mode)
const filteredPlans = computed(() => {
if (props.isSubscribeMode) {
// In subscribe mode (no subscription), show all plans including Hacker
return props.plans;
}
// In change plan mode (has subscription), hide Hacker plan
return props.plans.filter(
plan => plan.display_name?.toLowerCase() !== 'hacker'
);
});
const isCurrentPlanSelected = computed(() => {
return selectedPlanId.value === props.currentPlanId;
});
const modalTitle = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_TITLE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.TITLE');
});
const modalDescription = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_DESCRIPTION');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.DESCRIPTION');
});
const confirmButtonLabel = computed(() => {
if (isCurrentPlanSelected.value) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN');
}
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CHANGE_PLAN');
});
const handlePlanSelect = plan => {
selectedPlanId.value = plan.id;
};
const handleConfirm = () => {
if (!isCurrentPlanSelected.value && selectedPlan.value) {
emit('select', selectedPlan.value);
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
selectedPlanId.value = props.currentPlanId;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="modalTitle"
:description="modalDescription"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-3 gap-4">
<PlanCard
v-for="plan in filteredPlans"
:key="plan.id"
:plan="plan"
:is-selected="selectedPlanId === plan.id"
:is-current="plan.id === currentPlanId"
:is-disabled="isLoading || plan.id === currentPlanId"
@select="handlePlanSelect"
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CANCEL')"
class="w-full"
:disabled="isLoading"
@click="close"
/>
<Button
color="slate"
solid
:label="confirmButtonLabel"
class="w-full"
:disabled="isCurrentPlanSelected || isLoading"
:is-loading="isLoading"
@click="handleConfirm"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,146 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
creditGrants: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const dialogRef = ref(null);
const formattedGrants = computed(() => {
return props.creditGrants.map(grant => ({
...grant,
formattedEffectiveAt: grant.effective_at
? format(new Date(grant.effective_at), 'dd MMM, yyyy')
: '-',
formattedExpiresAt: grant.expires_at
? format(new Date(grant.expires_at), 'dd MMM, yyyy')
: '-',
}));
});
const handleClose = () => {
emit('close');
};
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.CREDIT_HISTORY.TITLE')"
:description="t('BILLING_SETTINGS.CREDIT_HISTORY.DESCRIPTION')"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
@close="handleClose"
>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="i-lucide-loader-2 size-6 animate-spin text-n-slate-11" />
</div>
<div v-else-if="formattedGrants.length === 0" class="py-8 text-center">
<span
class="i-lucide-credit-card size-12 text-n-slate-9 mx-auto block mb-3"
/>
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NO_RECORDS') }}
</p>
</div>
<div v-else class="overflow-hidden border rounded-lg border-n-weak">
<table class="w-full">
<thead class="bg-n-alpha-1">
<tr>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NAME') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.CREDITS') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.SOURCE') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EFFECTIVE_AT') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EXPIRES_AT') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr v-for="grant in formattedGrants" :key="grant.id">
<td class="px-4 py-3 text-sm text-n-slate-12">
{{ grant.name || '-' }}
</td>
<td
class="px-4 py-3 text-sm font-medium tabular-nums text-n-slate-12"
>
{{ grant.credits?.toLocaleString() || 0 }}
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs font-medium rounded bg-n-alpha-2 text-n-slate-11"
>
{{ grant.source || grant.category || '-' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedEffectiveAt }}
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedExpiresAt }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end w-full">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CREDIT_HISTORY.CLOSE')"
@click="close"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,8 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
defineProps({
credits: {
type: Number,
required: true,
@@ -11,68 +8,94 @@ const props = defineProps({
type: Number,
required: true,
},
isPopular: {
type: Boolean,
default: false,
currency: {
type: String,
default: 'usd',
},
isSelected: {
type: Boolean,
default: false,
},
isPopular: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
});
defineEmits(['select']);
const emit = defineEmits(['select']);
const { t } = useI18n();
const formatCredits = credits => {
return credits.toLocaleString();
};
const formattedCredits = computed(() => {
return props.credits.toLocaleString();
});
const formattedAmount = computed(() => {
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(props.amount);
});
const cardClasses = computed(() => {
const baseClasses =
'relative flex flex-col gap-2 p-4 transition-all border rounded-xl cursor-pointer';
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5`;
}
return `${baseClasses} border-n-weak hover:border-n-strong`;
});
}).format(amount);
};
</script>
<template>
<div :class="cardClasses" @click="$emit('select')">
<div
<label
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
:class="[
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
]"
>
<input
type="radio"
:name="name"
:value="credits"
:checked="isSelected"
class="sr-only"
@change="emit('select')"
/>
<span
v-if="isPopular"
class="absolute px-2 py-1 text-xs font-medium text-white rounded -top-3 left-3 bg-n-teal-9"
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
:class="
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
"
>
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.MOST_POPULAR') }}
</div>
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
</span>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
>
<span class="text-white i-lucide-check size-4" />
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="text-2xl font-semibold text-n-slate-12">
{{ formattedCredits }}
</div>
<div class="text-xs font-medium tracking-wider uppercase text-n-slate-10">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.CREDITS') }}
</div>
<div class="mt-2">
<span class="text-lg font-semibold text-n-slate-12">{{
formattedAmount
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
{{ formatCredits(credits) }}
</span>
<span
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
>
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
</span>
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
{{ formatAmount(amount, currency) }}
<span class="text-sm text-n-slate-11 ml-0.5">{{
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
}}</span>
</div>
</div>
</span>
</label>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
plan: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isCurrent: {
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
const { t } = useI18n();
const displayPrice = computed(() => {
const licenseFee = props.plan.components?.find(c => c.type === 'license_fee');
const amount = licenseFee?.unit_amount || 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
});
const monthlyCredits = computed(() => {
const serviceAction = props.plan.components?.find(
c => c.type === 'service_action'
);
return serviceAction?.credit_amount || 0;
});
const cardClasses = computed(() => {
const baseClasses = 'relative flex flex-col gap-2 p-4 transition-all border rounded-xl';
if (props.isDisabled) {
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 opacity-70 cursor-not-allowed`;
}
return `${baseClasses} border-n-weak opacity-50 cursor-not-allowed`;
}
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 cursor-pointer`;
}
return `${baseClasses} border-n-weak hover:border-n-strong cursor-pointer`;
});
const handleClick = () => {
if (!props.isDisabled) {
// Emit is handled in template
}
};
</script>
<template>
<div :class="cardClasses" @click="!isDisabled && $emit('select', plan)">
<div
v-if="isCurrent"
class="absolute px-2 py-1 text-xs font-medium rounded -top-3 left-3 bg-n-solid-3 text-n-slate-11"
>
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN') }}
</div>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
>
<span class="text-white i-lucide-check size-4" />
</div>
<h3 class="text-lg font-medium text-n-slate-12">
{{ plan.display_name }}
</h3>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-n-slate-12">
{{ displayPrice }}
</span>
<span class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.PER_USER_MONTH') }}
</span>
</div>
<div class="text-sm text-n-slate-11">
{{ monthlyCredits }}
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.AI_CREDITS_MONTH') }}
</div>
</div>
</template>
@@ -1,60 +1,119 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['purchase', 'close']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
// The 3rd option (5000 credits) is marked as most popular based on Figma
const popularCreditsAmount = 5000;
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return props.options.find(o => o.credits === selectedCredits.value);
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
if (!selectedOption.value) return '';
return selectedOption.value.credits.toLocaleString();
});
const dialogTitle = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
});
const dialogDescription = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
: '';
});
const dialogWidth = computed(() => {
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const handlePurchase = () => {
if (selectedOption.value) {
emit('purchase', selectedOption.value);
}
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = props.options.find(
o => o.credits === popularCreditsAmount
);
selectedCredits.value = popularOption?.credits || props.options[0]?.credits;
dialogRef.value?.open();
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
};
const close = () => {
dialogRef.value?.close();
const goBackToSelectStep = () => {
currentStep.value = STEP_SELECT;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
isLoading.value = true;
try {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
close();
emit('success', response.data);
useAlert(
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
credits: response.data.credits,
})
);
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
@@ -63,46 +122,95 @@ defineExpose({ open, close });
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.PURCHASE_MODAL.TITLE')"
:description="t('BILLING_SETTINGS.PURCHASE_MODAL.DESCRIPTION')"
width="xl"
:title="dialogTitle"
:description="dialogDescription"
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in options"
:key="option.credits"
:credits="option.credits"
:amount="option.amount"
:is-popular="option.credits === popularCreditsAmount"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.NOTE') }}
</p>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
<template v-else>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
credits: formattedCredits,
amount: formattedAmount,
})
}}
</p>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
</p>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.CANCEL')"
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="teal"
solid
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.PURCHASE')"
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
<!-- Step 2 Footer -->
<div v-else class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
class="w-full"
:disabled="isLoading"
@click="goBackToSelectStep"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
class="w-full"
:is-loading="isLoading"
@click="handlePurchase"
/>
@@ -1,104 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
min: {
type: Number,
default: 1,
},
max: {
type: Number,
default: 999,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
const localValue = ref(props.modelValue);
watch(
() => props.modelValue,
newVal => {
localValue.value = newVal;
}
);
const decrement = () => {
if (localValue.value > props.min) {
localValue.value -= 1;
emit('update:modelValue', localValue.value);
}
};
const increment = () => {
if (localValue.value < props.max) {
localValue.value += 1;
emit('update:modelValue', localValue.value);
}
};
const handleSave = () => {
emit('save', localValue.value);
};
const handleCancel = () => {
localValue.value = props.modelValue;
emit('cancel');
};
</script>
<template>
<div class="flex items-center gap-4">
<div
class="flex items-center overflow-hidden border rounded-lg border-n-weak"
>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue <= min || isLoading"
@click="decrement"
>
<span class="i-lucide-minus size-4" />
</button>
<span
class="flex items-center justify-center w-12 h-10 text-base font-medium tabular-nums text-n-slate-12"
>
{{ localValue }}
</span>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue >= max || isLoading"
@click="increment"
>
<span class="i-lucide-plus size-4" />
</button>
</div>
<div class="flex items-center gap-2">
<Button
variant="link"
color="slate"
label="Cancel"
:disabled="isLoading"
@click="handleCancel"
/>
<Button
solid
slate
sm
label="Save"
:is-loading="isLoading"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -1,56 +0,0 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
default: '',
},
actionText: {
type: String,
default: '',
},
actionDisabled: {
type: Boolean,
default: false,
},
showAction: {
type: Boolean,
default: true,
},
});
defineEmits(['action']);
</script>
<template>
<div
class="flex items-center justify-between py-4 border-b border-n-weak last:border-b-0"
>
<div class="flex flex-col gap-1">
<span
class="text-xs font-medium tracking-wider uppercase text-n-slate-10"
>
{{ label }}
</span>
<slot name="value">
<span class="text-base font-medium text-n-slate-12">
{{ value }}
</span>
</slot>
</div>
<slot name="action">
<button
v-if="actionText && showAction"
type="button"
class="text-sm font-medium transition-colors text-n-slate-11 hover:text-n-slate-12 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="actionDisabled"
@click="$emit('action')"
>
{{ actionText }}
</button>
</slot>
</div>
</template>
@@ -110,6 +110,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@@ -114,6 +114,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -626,7 +624,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -76,7 +75,6 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -337,7 +335,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>
@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -147,7 +145,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -12,7 +11,6 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
watch(
() => props.messageSignature ?? '',
@@ -34,7 +32,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
@@ -18,17 +18,8 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingPricingPlans: false,
isFetchingTopupOptions: false,
isFetchingCreditGrants: false,
isChangingPlan: false,
isCancellingSubscription: false,
isPurchasingCredits: false,
isSubscribing: false,
isFetchingLimits: false,
},
pricingPlans: [],
topupOptions: [],
creditGrants: [],
};
export const getters = {
@@ -38,15 +29,6 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getPricingPlans($state) {
return $state.pricingPlans;
},
getTopupOptions($state) {
return $state.topupOptions;
},
getCreditGrants($state) {
return $state.creditGrants;
},
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
@@ -160,131 +142,20 @@ export const actions = {
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
// V2 Billing Actions
fetchPricingPlans: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingPricingPlans: true });
try {
const response = await EnterpriseAccountAPI.getPricingPlans();
commit(types.default.SET_PRICING_PLANS, response.data.pricing_plans);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingPricingPlans: false,
});
}
},
fetchTopupOptions: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingTopupOptions: true });
try {
const response = await EnterpriseAccountAPI.getTopupOptions();
commit(types.default.SET_TOPUP_OPTIONS, response.data.topup_options);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingTopupOptions: false,
});
}
},
fetchCreditGrants: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingCreditGrants: true });
try {
const response = await EnterpriseAccountAPI.getCreditGrants();
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingCreditGrants: false,
});
}
},
changePricingPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: true });
try {
const response = await EnterpriseAccountAPI.changePricingPlan(
pricingPlanId,
quantity
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: false });
}
},
cancelAccountSubscription: async ({ commit }, { reason, feedback }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: true,
});
try {
const response = await EnterpriseAccountAPI.cancelSubscription(
reason,
feedback
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: false,
});
}
},
purchaseCredits: async ({ commit }, { credits }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: true });
try {
const response = await EnterpriseAccountAPI.topupCredits(credits);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: false });
}
},
subscribeToPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: true });
try {
const response = await EnterpriseAccountAPI.subscribeToPlan(
pricingPlanId,
quantity
);
// Redirect to Stripe checkout
if (response.data.redirect_url) {
window.location = response.data.redirect_url;
}
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: false });
}
},
};
export const mutations = {
@@ -297,15 +168,6 @@ export const mutations = {
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
[types.default.SET_PRICING_PLANS]($state, data) {
$state.pricingPlans = data;
},
[types.default.SET_TOPUP_OPTIONS]($state, data) {
$state.topupOptions = data;
},
[types.default.SET_CREDIT_GRANTS]($state, data) {
$state.creditGrants = data;
},
};
export default {
@@ -76,16 +76,13 @@ export default {
EDIT_INBOXES: 'EDIT_INBOXES',
DELETE_INBOXES: 'DELETE_INBOXES',
// Account
// Agent
SET_ACCOUNT_UI_FLAG: 'SET_ACCOUNT_UI_FLAG',
SET_ACCOUNT_LIMITS: 'SET_ACCOUNT_LIMITS',
SET_ACCOUNTS: 'SET_ACCOUNTS',
ADD_ACCOUNT: 'ADD_ACCOUNT',
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
DELETE_ACCOUNT: 'DELETE_AGENT',
SET_PRICING_PLANS: 'SET_PRICING_PLANS',
SET_TOPUP_OPTIONS: 'SET_TOPUP_OPTIONS',
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
// Agent
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',
@@ -111,10 +111,15 @@ export default {
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
toggleSignatureInEditor(signatureEnabled) {
const valueWithSignature = signatureEnabled
let valueWithSignature = signatureEnabled
? appendSignature(this.modelValue, this.cleanedSignature)
: removeSignature(this.modelValue, this.cleanedSignature);
// Clean up whitespace when removing signature from empty body
if (!signatureEnabled && !valueWithSignature.trim()) {
valueWithSignature = '';
}
this.$emit('update:modelValue', valueWithSignature);
this.$emit('input', valueWithSignature);
@@ -8,4 +8,8 @@ export const OPEN_AI_OPTIONS = {
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
CASUAL: 'casual',
PROFESSIONAL: 'professional',
STRAIGHTFORWARD: 'straightforward',
CONFIDENT: 'confident',
};
+36 -15
View File
@@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob
def parse_csv_and_build_contacts
contacts = []
rejected_contacts = []
# Ensuring that importing non utf-8 characters will not throw error
data = @data_import.import_file.download
utf8_data = data.force_encoding('UTF-8')
# Ensure that the data is valid UTF-8, preserving valid characters
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
csv = CSV.parse(clean_data, headers: true)
csv.each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
with_import_file do |file|
csv_reader(file).each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
@@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob
end
def generate_csv_data(rejected_contacts)
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
@@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
CSV.new(StringIO.new(clean_data), headers: true)
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
end
end
end
+9 -6
View File
@@ -2,10 +2,6 @@ class DeleteObjectJob < ApplicationJob
queue_as :low
BATCH_SIZE = 5_000
HEAVY_ASSOCIATIONS = {
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
def perform(object, user = nil, ip = nil)
# Pre-purge heavy associations for large objects to avoid
@@ -19,11 +15,18 @@ class DeleteObjectJob < ApplicationJob
private
def heavy_associations
{
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
end
def purge_heavy_associations(object)
klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) }
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
return unless klass
HEAVY_ASSOCIATIONS[klass].each do |assoc|
heavy_associations[klass].each do |assoc|
next unless object.respond_to?(assoc)
batch_destroy(object.public_send(assoc))
@@ -0,0 +1,51 @@
# Handles attachment processing for ConversationReplyMailer flows.
module ConversationReplyMailerAttachmentHelper
private
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
current_total_size = handle_attachment_inline(current_total_size, attachment)
end
end
def read_blob_content(blob)
buffer = +''
blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def handle_attachment_inline(current_total_size, attachment)
blob = attachment.file.blob
return current_total_size if blob.blank?
file_size = blob.byte_size
attachment_name = attachment.file.filename.to_s
if current_total_size + file_size <= 20.megabytes
content = read_blob_content(blob)
mail.attachments[attachment_name] = {
mime_type: attachment.file.content_type || 'application/octet-stream',
content: content
}
@options[:attachments] << { name: attachment_name }
current_total_size + file_size
else
@large_attachments << attachment
current_total_size
end
end
end
@@ -1,4 +1,6 @@
module ConversationReplyMailerHelper
include ConversationReplyMailerAttachmentHelper
def prepare_mail(cc_bcc_enabled)
@options = {
to: to_emails,
@@ -27,34 +29,6 @@ module ConversationReplyMailerHelper
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def oauth_smtp_settings
+1 -1
View File
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9 }
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
def push_event_data
return unless file_type
+21 -12
View File
@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
# Escape HTML entities first to prevent HTML injection
# This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# convert markdown to html
html = CommonMarker.render_html(text).strip
# Parse markdown with extensions:
# - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert paragraph breaks to double newlines to preserve them
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
# Remove opening and closing <p> tags
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
text_payload = convert_markdown_to_telegram_html(text)
# text is already converted to HTML by MessageContentPresenter
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text_payload,
text: text,
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
+1 -1
View File
@@ -53,7 +53,7 @@ class Integrations::App
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
+15
View File
@@ -254,6 +254,21 @@ class Message < ApplicationRecord
Messages::SearchDataPresenter.new(self).search_data
end
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[Attachment]' if attachments.any?
end
private
def prevent_message_flooding
+1 -42
View File
@@ -31,48 +31,7 @@ class AccountPolicy < ApplicationPolicy
@account_user.administrator?
end
def v2_pricing_plans?
@account_user.administrator?
end
def v2_topup_options?
@account_user.administrator?
end
def v2_topup?
@account_user.administrator?
end
def v2_subscribe?
@account_user.administrator?
end
def cancel_subscription?
@account_user.administrator?
end
def credit_grants?
@account_user.administrator?
end
def change_pricing_plan?
@account_user.administrator?
end
# V2 Billing API actions
def pricing_plans?
@account_user.administrator?
end
def topup_options?
@account_user.administrator?
end
def topup?
@account_user.administrator?
end
def subscribe?
def topup_checkout?
@account_user.administrator?
end
end
+12 -5
View File
@@ -1,11 +1,18 @@
class MessageContentPresenter < SimpleDelegator
def outgoing_content
return content unless should_append_survey_link?
content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
Messages::MarkdownRendererService.new(
content_to_send,
conversation.inbox.channel_type,
conversation.inbox.channel
).render
end
private
+9
View File
@@ -59,11 +59,20 @@ class Instagram::MessageText < Instagram::BaseMessageText
# We can safely create an unknown contact, making this integration work.
return unknown_user(ig_scope_id) if error_code == 9010
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions
# We can safely create an unknown contact, similar to error 9010
return unknown_user(ig_scope_id) if error_code == 100
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result from being returned
{}
end
def base_uri
@@ -48,7 +48,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
'Bot'
end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
"#{sender}: #{message.content_for_llm}\n"
end
def build_attributes
@@ -0,0 +1,113 @@
class Messages::MarkdownRendererService
CHANNEL_RENDERERS = {
'Channel::Email' => :render_html,
'Channel::WebWidget' => :render_html,
'Channel::Telegram' => :render_telegram_html,
'Channel::Whatsapp' => :render_whatsapp,
'Channel::FacebookPage' => :render_instagram,
'Channel::Instagram' => :render_instagram,
'Channel::Line' => :render_line,
'Channel::TwitterProfile' => :render_plain_text,
'Channel::Sms' => :render_plain_text,
'Channel::TwilioSms' => :render_plain_text
}.freeze
def initialize(content, channel_type, channel = nil)
@content = content
@channel_type = channel_type
@channel = channel
end
def render
return @content if @content.blank?
renderer_method = CHANNEL_RENDERERS[effective_channel_type]
renderer_method ? send(renderer_method) : @content
end
private
def effective_channel_type
# For Twilio SMS channel, check if it's actually WhatsApp
if @channel_type == 'Channel::TwilioSms' && @channel&.whatsapp?
'Channel::Whatsapp'
else
@channel_type
end
end
def commonmarker_doc
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
end
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end
def render_telegram_html
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_whatsapp
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_instagram
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_line
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::LineRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_plain_text
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
def preserve_multiple_newlines(content)
content.gsub(/\n{2,}/) do |match|
"{{PRESERVE_#{match.length}_NEWLINES}}"
end
end
# Restore multiple newlines from placeholders
def restore_multiple_newlines(content)
content.gsub(/\{\{PRESERVE_(\d+)_NEWLINES\}\}/) do |_match|
"\n" * Regexp.last_match(1).to_i
end
end
end
@@ -0,0 +1,39 @@
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
def document(_node)
out(:children)
end
def paragraph(_node)
out(:children)
cr
end
def text(node)
out(node.string_content)
end
def softbreak(_node)
out(' ')
end
def linebreak(_node)
out("\n")
end
def strikethrough(_node)
out('<del>')
out(:children)
out('</del>')
end
def method_missing(method_name, node = nil, *args, **kwargs, &)
return super unless node.is_a?(CommonMarker::Node)
out(:children)
cr unless %i[text softbreak linebreak].include?(node.type)
end
def respond_to_missing?(_method_name, _include_private = false)
true
end
end
@@ -0,0 +1,48 @@
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out(node.string_content)
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -0,0 +1,36 @@
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out(' *', :children, '* ')
end
def emph(_node)
out(' _', :children, '_ ')
end
def code(node)
out(' `', node.string_content, '` ')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out(:children)
cr
end
def code_block(node)
out(' ```', "\n", node.string_content, '``` ', "\n")
end
def blockquote(_node)
out(:children)
cr
end
end
@@ -0,0 +1,62 @@
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def link(node)
out(:children)
out(' ', node.url) if node.url.present?
end
def strong(_node)
out(:children)
end
def emph(_node)
out(:children)
end
def code(node)
out(node.string_content)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content, "\n")
end
def header(_node)
out(:children)
cr
end
def thematic_break(_node)
out("\n")
end
def softbreak(_node)
out("\n")
end
end
@@ -0,0 +1,60 @@
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('<strong>', :children, '</strong>')
end
def emph(_node)
out('<em>', :children, '</em>')
end
def code(node)
out('<code>', node.string_content, '</code>')
end
def link(node)
out('<a href="', node.url, '">', :children, '</a>')
end
def strikethrough(_node)
out('<del>', :children, '</del>')
end
def blockquote(_node)
out('<blockquote>', :children, '</blockquote>')
end
def code_block(node)
out('<pre>', node.string_content, '</pre>')
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('• ', :children)
end
cr
end
def header(_node)
out('<strong>', :children, '</strong>')
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -0,0 +1,36 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out('`', node.string_content, '`')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out('- ', :children)
cr
end
def blockquote(_node)
out('> ', :children)
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -96,11 +96,16 @@ class Telegram::SendAttachmentsService
# Telegram picks up the file name from original field name, so we need to save the file with the original name.
# Hence not using Tempfile here.
def save_attachment_to_tempfile(attachment)
raw_data = attachment.file.download
temp_dir = Rails.root.join('tmp/uploads')
temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}")
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
File.write(temp_file_path, raw_data, mode: 'wb')
File.open(temp_file_path, 'wb') do |file|
attachment.file.blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
temp_file_path
end
@@ -0,0 +1,49 @@
class Whatsapp::CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
def self.csat_template_name(inbox_id, version = nil)
base_name = csat_base_name_for_inbox(inbox_id)
version ? "#{base_name}_#{version}" : base_name
end
def self.extract_version(template_name, inbox_id)
return nil if template_name.blank?
pattern = versioned_pattern_for_inbox(inbox_id)
match = template_name.match(pattern)
match ? match[1].to_i : nil
end
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
return base_name if current_template_name.blank?
current_version = extract_version(current_template_name, inbox_id)
next_version = current_version ? current_version + 1 : 1
csat_template_name(inbox_id, next_version)
end
def self.matches_csat_pattern?(template_name, inbox_id)
return false if template_name.blank?
base_pattern = base_pattern_for_inbox(inbox_id)
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
end
def self.csat_base_name_for_inbox(inbox_id)
"#{CSAT_BASE_NAME}_#{inbox_id}"
end
def self.base_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
end
def self.versioned_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
end
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
end
@@ -0,0 +1,139 @@
class Whatsapp::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
WHATSAPP_API_VERSION = 'v14.0'.freeze
TEMPLATE_CATEGORY = 'MARKETING'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)
@whatsapp_channel = whatsapp_channel
end
def create_template(template_config)
base_name = template_config[:template_name]
template_name = generate_template_name(base_name)
template_config_with_name = template_config.merge(template_name: template_name)
request_body = build_template_request_body(template_config_with_name)
response = send_template_creation_request(request_body)
process_template_creation_response(response, template_config_with_name)
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
)
{ success: response.success?, response_body: response.body }
end
def get_template_status(template_name)
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
if response.success? && response['data']&.any?
template_data = response['data'].first
{
success: true,
template: {
id: template_data['id'], name: template_data['name'],
status: template_data['status'], language: template_data['language']
}
}
else
{ success: false, error: 'Template not found' }
end
rescue StandardError => e
Rails.logger.error "Error fetching template status: #{e.message}"
{ success: false, error: e.message }
end
private
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
end
def build_template_request_body(template_config)
{
name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
category: TEMPLATE_CATEGORY,
components: build_template_components(template_config)
}
end
def build_template_components(template_config)
[
build_body_component(template_config[:message]),
build_buttons_component(template_config)
]
end
def build_body_component(message)
{
type: 'BODY',
text: message
}
end
def build_buttons_component(template_config)
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
example: ['12345']
}
]
}
end
def send_template_creation_request(request_body)
HTTParty.post(
"#{business_account_path}/message_templates",
headers: api_headers,
body: request_body.to_json
)
end
def process_template_creation_response(response, template_config = {})
if response.success?
{
success: true,
template_id: response['id'],
template_name: response['name'] || template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def business_account_path
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
end
def api_headers
{
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
end
@@ -62,12 +62,31 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def create_csat_template(template_config)
csat_template_service.create_template(template_config)
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
def get_template_status(template_name)
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end
private
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
+1 -14
View File
@@ -5,20 +5,7 @@ if resource.custom_attributes.present?
json.plan_name resource.custom_attributes['plan_name']
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_at resource.custom_attributes['subscription_ends_at']
json.subscription_cancelled_at resource.custom_attributes['subscription_cancelled_at'] if resource.custom_attributes['subscription_cancelled_at'].present?
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
json.stripe_plan_id resource.custom_attributes['stripe_plan_id'] if resource.custom_attributes['stripe_plan_id'].present?
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
if resource.custom_attributes['pending_stripe_pricing_plan_id'].present?
json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id']
end
if resource.custom_attributes['pending_subscription_quantity'].present?
json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity']
end
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
json.next_billing_date resource.custom_attributes['next_billing_date'] if resource.custom_attributes['next_billing_date'].present?
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
@@ -3,6 +3,6 @@ json.payload do
json.id inbox_member.user.id
json.name inbox_member.user.available_name
json.avatar_url inbox_member.user.avatar_url
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id)&.availability_status
end
end
@@ -1,7 +1,7 @@
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<%= @message.outgoing_content.html_safe %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
+2 -2
View File
@@ -4,8 +4,8 @@ require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
if api_key.present?
Agents.configure do |config|
+9 -11
View File
@@ -122,12 +122,13 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
enterprise:
billing:
topup_amount_invalid: Topup amount must be greater than 0
stripe_customer_required: Customer ID required. Please create a Stripe customer first.
lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id}
v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.
topup:
credits_required: Credits amount is required
invalid_credits: Invalid credits amount
invalid_option: Invalid topup option
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
profile:
mfa:
enabled: MFA enabled successfully
@@ -203,6 +204,8 @@ en:
messages:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
instagram_shared_story_content: 'Shared story'
instagram_shared_post_content: 'Shared post'
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
@@ -444,8 +447,3 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
enterprise:
billing:
topup_successful: Topup successful
subscription_cancelled: Subscription cancelled
pricing_plan_changed: Pricing plan changed
+3 -14
View File
@@ -203,6 +203,8 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
@@ -438,20 +440,7 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
end
end
end
namespace :v2 do
resources :accounts, only: [] do
resource :billing, only: [], controller: 'billing' do
get :credit_grants
get :pricing_plans
get :topup_options
post :topup
post :subscribe
post :cancel_subscription
post :change_pricing_plan
post :topup_checkout
end
end
end
-8
View File
@@ -1,8 +0,0 @@
# Stripe V2 Billing Scheduled Jobs
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
v2_credit_sync:
cron: "0 * * * *" # Every hour
class: "Enterprise::Billing::CreditSyncJob"
queue: low
description: "Sync V2 billing credits with Stripe"
@@ -17,7 +17,7 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
end
def destroy
@sla_policy.destroy!
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
head :ok
end
@@ -1,6 +1,5 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
@@ -56,6 +55,22 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
private
def check_cloud_env
@@ -1,121 +0,0 @@
class Enterprise::Api::V2::BillingController < Api::BaseController
before_action :fetch_account
before_action :check_billing_authorization
before_action :validate_topup_amount, only: [:topup]
rescue_from StandardError, with: :render_error
rescue_from NotImplementedError, with: :render_not_implemented
def credit_grants
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
grants = service.fetch_credit_grants
render json: { credit_grants: grants }
end
def pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def topup
service = Enterprise::Billing::V2::TopupService.new(account: @account)
result = service.create_topup(credits: params[:credits].to_i)
if result[:success]
render json: { success: true, message: result[:message] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def subscribe
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
redirect_url = service.create_subscription_checkout(
pricing_plan_id: params[:pricing_plan_id],
quantity: subscription_quantity
)
render json: { redirect_url: redirect_url }
end
def cancel_subscription
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
result = service.cancel_subscription(
reason: params[:reason],
feedback: params[:feedback]
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def change_pricing_plan
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
result = service.change_plan(
new_pricing_plan_id: params[:pricing_plan_id],
quantity: params[:quantity]&.to_i
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def fetch_account
@account = current_user.accounts.find(params[:account_id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def subscription_quantity
[params[:quantity].to_i, 1].max
end
def validate_topup_amount
return if params[:credits].to_i.positive?
render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
def render_error(exception)
render json: { error: exception.message }, status: :unprocessable_entity
end
def render_not_implemented(exception)
render json: { error: exception.message }, status: :not_implemented
end
def check_billing_authorization
authorize(@account, "#{action_name}?".to_sym)
end
end
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params) if list_params[:query].present?
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
@@ -6,17 +6,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# Attempt to verify the signature. If successful, we'll handle the event
begin
# Determine which webhook secret to use based on event type
webhook_secret = determine_webhook_secret(payload)
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
# Check if this is a V2 billing event
if v2_billing_event?(event.type)
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
else
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
end
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
@@ -27,27 +18,4 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# We've successfully processed the event without blowing up
head :ok
end
private
def determine_webhook_secret(payload)
# Parse the payload to check event type without full verification
parsed_payload = JSON.parse(payload)
event_type = parsed_payload['type']
return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank?
if v2_billing_event?(event_type)
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
else
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
end
end
def v2_billing_event?(event_type)
return false if event_type.blank?
Rails.logger.debug { "V2 billing event: #{event_type}" }
event_type.start_with?('v2.')
end
end
+62 -20
View File
@@ -1,31 +1,81 @@
module Captain::ChatHelper
include Integrations::LlmInstrumentation
include Captain::ToolExecutionHelper
include Captain::ChatResponseHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
with_agent_session do
response = instrument_llm_call(instrumentation_params) do
@client.chat(
parameters: chat_parameters
)
end
handle_response(response)
response = chat.ask(conversation_messages.last[:content])
build_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
def instrumentation_params
private
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
end
def setup_tools(llm_chat)
@tools&.each do |tool|
llm_chat = llm_chat.with_tool(tool)
end
llm_chat
end
def setup_system_instructions(chat)
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
combined_instructions = system_messages.pluck(:content).join("\n\n")
chat.with_instructions(combined_instructions)
end
def setup_event_handlers(chat)
chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) }
chat.on_end_message { |message| end_llm_turn_span(message) }
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
chat.on_tool_result { |result| handle_tool_result(result) }
chat
end
def handle_tool_call(tool_call)
persist_thinking_message(tool_call)
start_tool_span(tool_call)
@pending_tool_calls ||= []
@pending_tool_calls.push(tool_call)
end
def handle_tool_result(result)
end_tool_span(result)
persist_tool_completion
end
def add_messages_to_chat(chat)
conversation_messages[0...-1].each do |msg|
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
end
end
def instrumentation_params(chat = nil)
{
span_name: "llm.captain.#{feature_name}",
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
messages: @messages,
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
temperature: temperature,
metadata: {
assistant_id: @assistant&.id
@@ -33,14 +83,8 @@ module Captain::ChatHelper
}
end
def chat_parameters
{
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: temperature
}
def conversation_messages
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
end
def temperature
@@ -51,8 +95,6 @@ module Captain::ChatHelper
@account&.id || @assistant&.account_id
end
private
# Ensures all LLM calls and tool executions within an agentic loop
# are grouped under a single trace/session in Langfuse.
#
@@ -78,7 +120,7 @@ module Captain::ChatHelper
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
for messages #{@messages} with #{@tools&.length || 0} tools
"
)
end
@@ -0,0 +1,52 @@
module Captain::ChatResponseHelper
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
persist_message(parsed, 'assistant')
parsed
end
def parse_json_response(content)
content = content.gsub('```json', '').gsub('```', '')
content = content.strip
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
{ 'content' => content }
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Using #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
def persist_tool_completion
return if @copilot_thread.blank?
tool_call = @pending_tool_calls&.pop
return unless tool_call
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Completed #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
end
@@ -1,83 +0,0 @@
module Captain::ToolExecutionHelper
private
def handle_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
tool_calls.each { |tool_call| process_tool_call(tool_call) }
request_chat_completion
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_tool_status(function_name, 'captain.copilot.using_tool')
result = perform_tool_call(function_name, arguments)
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
append_tool_response(result, tool_call_id)
end
def perform_tool_call(function_name, arguments)
instrument_tool_call(function_name, arguments) do
@tool_registry.send(function_name, arguments)
end
rescue StandardError => e
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
"Error executing #{function_name}: #{e.message}"
end
def persist_tool_status(function_name, translation_key)
persist_message(
{
content: I18n.t(translation_key, function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
tool_calls: tool_calls
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message(
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
'assistant_thinking'
)
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: content
}
end
end
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
end
def build_paginated_service(document, options)
@@ -2,7 +2,8 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
account_id = record.account_id
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
record.update!(embedding: embedding)
end
end
@@ -1,96 +0,0 @@
class Enterprise::Billing::CreditSyncJob < ApplicationJob
queue_as :low
def perform(account = nil)
if account
sync_single_account(account)
else
sync_all_accounts
end
end
private
def sync_all_accounts
Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts'
accounts_with_stripe = Account.where(
"custom_attributes->>'stripe_customer_id' IS NOT NULL AND (custom_attributes->>'stripe_billing_version')::integer = 2"
)
synced_count = 0
failed_count = 0
accounts_with_stripe.find_each do |account|
result = sync_account_credits(account)
if result[:success]
synced_count += 1 if result[:credits_reported].to_i.positive?
else
failed_count += 1
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
end
Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}"
{ synced: synced_count, failed: failed_count }
end
def sync_single_account(account)
Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}"
result = sync_account_credits(account)
if result[:success]
Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}"
else
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
result
end
def sync_account_credits(account)
consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i
last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i
credits_to_report = consumed_credits - last_synced_credits
if credits_to_report.positive?
handle_positive_credits(account, credits_to_report, consumed_credits)
elsif credits_to_report.negative?
handle_negative_credits(account, credits_to_report, consumed_credits)
else
{ success: true, message: 'Already in sync', credits_reported: 0 }
end
rescue StandardError => e
handle_sync_error(account, e)
end
def handle_positive_credits(account, credits_to_report, consumed_credits)
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
result = reporter.report(credits_to_report)
return result unless result[:success]
update_last_synced_credits(account, consumed_credits)
Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})"
result.merge(credits_reported: credits_to_report)
end
def handle_negative_credits(account, credits_to_report, consumed_credits)
Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}"
update_last_synced_credits(account, consumed_credits)
{ success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 }
end
def handle_sync_error(account, error)
Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
{ success: false, message: error.message }
end
def update_last_synced_credits(account, credits)
account.with_lock do
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
current_attributes['stripe_last_synced_credits'] = credits
account.update!(custom_attributes: current_attributes)
end
end
end
@@ -1,10 +1,18 @@
module Enterprise::DeleteObjectJob
private
def heavy_associations
super.merge(
SlaPolicy => %i[applied_slas]
).freeze
end
def process_post_deletion_tasks(object, user, ip)
create_audit_entry(object, user, ip)
end
def create_audit_entry(object, user, ip)
return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present?
return unless %w[Inbox Conversation SlaPolicy].include?(object.class.to_s) && user.present?
Enterprise::AuditLog.create(
auditable: object,
@@ -19,6 +19,8 @@ class ArticleEmbedding < ApplicationRecord
after_commit :update_response_embedding
delegate :account_id, to: :article
private
def update_response_embedding
@@ -44,8 +44,8 @@ class Captain::AssistantResponse < ApplicationRecord
enum status: { pending: 0, approved: 1 }
def self.search(query)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
def self.search(query, account_id: nil)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
+1 -1
View File
@@ -43,7 +43,7 @@ module Concerns::Agentable
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
end
def agent_response_schema
@@ -66,6 +66,31 @@ module Concerns::Toolable
[auth_config['username'], auth_config['password']]
end
def build_metadata_headers(state)
{}.tap do |headers|
add_base_headers(headers, state)
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
add_contact_headers(headers, state[:contact]) if state[:contact]
end
end
def add_base_headers(headers, state)
headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id]
headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id]
headers['X-Chatwoot-Tool-Slug'] = slug if slug.present?
end
def add_conversation_headers(headers, conversation)
headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id]
headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id]
end
def add_contact_headers(headers, contact)
headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id]
headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present?
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
end
def format_response(raw_response_body)
return raw_response_body if response_template.blank?
@@ -1,13 +1,6 @@
module Enterprise::Account::PlanUsageAndLimits
# Total credits
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
# Response credits breakdown (monthly + topup)
CAPTAIN_RESPONSES_MONTHLY = 'captain_responses_monthly'.freeze
CAPTAIN_RESPONSES_TOPUP = 'captain_responses_topup'.freeze
# Usage tracking
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
@@ -23,7 +16,8 @@ module Enterprise::Account::PlanUsageAndLimits
end
def increment_response_usage
custom_attributes[CAPTAIN_RESPONSES_USAGE] = (custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0) + 1
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
save
end
@@ -64,12 +58,11 @@ module Enterprise::Account::PlanUsageAndLimits
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
total_count: total_count,
monthly: (self[:limits][CAPTAIN_RESPONSES_MONTHLY].to_i if type == :responses),
topup: (self[:limits][CAPTAIN_RESPONSES_TOPUP].to_i if type == :responses),
current_available: (total_count - consumed).clamp(0, total_count),
consumed: consumed
}
@@ -103,12 +96,17 @@ module Enterprise::Account::PlanUsageAndLimits
end
def agent_limits
custom_attributes['subscribed_quantity'] || get_limits(:agents)
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
self[:limits][limit_name.to_s].presence || GlobalConfig.get(config_name)[config_name].presence || ChatwootApp.max_limit
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
ChatwootApp.max_limit
end
def validate_limit_keys
@@ -121,9 +119,7 @@ module Enterprise::Account::PlanUsageAndLimits
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' },
'captain_responses_monthly' => { 'type': 'number' },
'captain_responses_topup' => { 'type': 'number' }
'captain_documents' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
@@ -11,7 +11,7 @@ module Enterprise::Concerns::Article
add_article_embedding_association
def self.vector_search(params)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
records = joins(
:category
).search_by_category_slug(

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