Compare commits

...
Author SHA1 Message Date
Shivam Mishra 2eff74c854 feat: make response usage increment atomic 2026-01-08 21:26:14 +05:30
Shivam MishraandGitHub 566de02385 feat: allow agent bot and captain responses to reset waiting since (#13181)
When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.

### Timeline from Production Data

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

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

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

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

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

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

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

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

### What triggers the rest

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

## Related Work

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

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

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

## Type of change

Please delete options that are not relevant.

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-01-06 15:23:54 +05:30
3e5b2979eb feat: Add support for sending CSAT surveys via templates (Whatsapp Cloud) (#12787)
This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp
message templates, ensuring survey delivery even after the 24-hour
session window.

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

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

#### Why this change

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

With this update:

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

#### Screens & States

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

#### Test Cases


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

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

**2. CSAT toggle without changing text**

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

**3. Editing only survey rules**

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

**4. Template text change**

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

**5. Language change**

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

 **6. Sending survey**

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

**7. Non-WhatsApp inbox**

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


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

---------

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

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

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

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

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

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

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

## Type of change

Please delete options that are not relevant.

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


## Checklist:

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

<!-- CURSOR_SUMMARY -->
---

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

---------

Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-23 13:59:20 +07:00
Sojan Jose e7be8c14bd Merge branch 'release/4.9.1' into develop 2025-12-22 18:56:15 -08:00
Sojan Jose 62376701da Bump version to 4.9.1 2025-12-22 18:55:42 -08:00
Gabriel JablonskiandGitHub d2e6d6aee3 fix: Improve handling of empty custom attributes list in settings (#13127)
## Description

This PR fixes a UX bug in the Custom Attributes settings page where
switching tabs becomes impossible when the currently selected tab has no
attributes.

Closes #13120

### The Problem
When viewing the Custom Attributes settings page, if one tab (e.g.,
Conversation) has no attributes, users could not switch to the other tab
(e.g., Contact) which might have attributes.

### Root Cause
The `SettingsLayout` component receives `no-records-found` prop which,
when true, hides the entire body content including the TabBar. Since the
TabBar was inside the body slot, it would be hidden whenever the current
tab had no attributes, preventing users from switching tabs.

### The Fix
- Removed the `no-records-found` and `no-records-message` props from
`SettingsLayout`
- Moved the empty state message inline within the body, displayed below
the TabBar
- The TabBar is now always visible regardless of whether there are
attributes in the selected tab

### Key Changes
- Modified `Index.vue` to handle empty state inline while keeping TabBar
accessible

## Type of change

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

## How Has This Been Tested?

1. Navigate to Settings > Custom Attributes
2. Ensure only one tab (e.g., Contact) has custom attributes
3. Switch to the empty tab (e.g., Conversation)
4. Verify the TabBar remains visible and the empty state message is
shown
5. Switch back to the tab with attributes
6. Verify attributes are displayed correctly
7. Repeat with both tabs empty and both tabs with attributes
2025-12-22 15:10:45 -08:00
Sivin VargheseandGitHub 53c21e6ad3 fix: Prevent invalid attachments from blocking text paste (#13135) 2025-12-22 21:17:35 +05:30
d408f664cb fix: add linear integration for the startup plan (#13136)
Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-22 21:14:05 +05:30
Sojan Jose 20d97be4c9 Merge branch 'release/4.9.0' into develop 2025-12-19 19:13:03 -08:00
Sojan Jose 6365a7ea53 Bump version to 4.9.0 2025-12-19 19:12:13 -08:00
PranavandGitHub 2adc040a8f fix: Validate blob before attaching it to a record (#13115)
Previously, attachments relied only on blob_id, which made it possible
to attach blobs across accounts by enumerating IDs. We now require both
blob_id and blob_key, add cross-account validation to prevent blob
reuse, and centralize the logic in a shared BlobOwnershipValidation
concern.

It also fixes a frontend bug where mixed-type action params (number +
string) were incorrectly dropped, causing attachment uploads to fail.
2025-12-19 19:02:21 -08:00
PranavandGitHub 86da3f7c06 fix: Remove account_id from params since it is not used (#13116)
account_id was permitted in strong parameters, allowing authenticated
admins to transfer resources (Portals, Automation Rules, Macros) to
arbitrary accounts.

 Fix: Removed account_id from permitted params in 4 controllers:
  - portals_controller.rb
  - automation_rules_controller.rb
  - macros_controller.rb
  - twilio_channels_controller.rb
2025-12-19 17:07:53 -08:00
c22a31c198 feat: Voice Channel (#11602)
Enables agents to initiate outbound calls and receive incoming calls
directly from the Chatwoot dashboard, with Twilio as the initial
provider.

Fixes:  #11481 

> This is an integration branch to ensure features works well and might
be often broken on down merges, we will be extracting the
functionalities via smaller PRs into develop

- [x] https://github.com/chatwoot/chatwoot/pull/11775
- [x] https://github.com/chatwoot/chatwoot/pull/12218
- [x] https://github.com/chatwoot/chatwoot/pull/12243
- [x] https://github.com/chatwoot/chatwoot/pull/12268
- [x] https://github.com/chatwoot/chatwoot/pull/12361
- [x]  https://github.com/chatwoot/chatwoot/pull/12782
- [x] #13064
- [ ] Ability for agents to join the inbound calls ( included in this PR
)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-12-19 12:41:33 -08:00
69 changed files with 2488 additions and 169 deletions
+2
View File
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+16 -13
View File
@@ -140,24 +140,27 @@ GEM
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
multi_json (~> 1)
+1 -1
View File
@@ -1 +1 @@
4.8.0
4.9.1
@@ -1,4 +1,6 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@automation_rule.actions = actions
@automation_rule.conditions = params[:conditions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
@automation_rule.save!
process_attachments
@automation_rule
blobs.each { |blob| @automation_rule.files.attach(blob) }
end
def update
ActiveRecord::Base.transaction do
automation_rule_update
process_attachments
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit)
@automation_rule.actions = actions if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
render_could_not_create_error(@automation_rule.errors.messages)
end
end
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id, :active,
:name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
@@ -1,4 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = params[:actions]
@macro.actions = actions
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
@macro.save!
process_attachments
@macro
blobs.each { |blob| @macro.files.attach(blob) }
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.assign_attributes(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.actions = actions if params[:actions]
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
render_could_not_create_error(@macro.errors.messages)
end
end
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
:name, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
end
def render_error(message, status)
@@ -0,0 +1,35 @@
module AttachmentConcern
extend ActiveSupport::Concern
def validate_and_prepare_attachments(actions, record = nil)
blobs = []
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
next action unless action[:action_name] == 'send_attachment'
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
result
end
[blobs, sanitized, nil]
end
private
def process_attachment_action(action, record, blobs)
blob_id = action[:action_params].first
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end
end
@@ -0,0 +1,95 @@
import { Device } from '@twilio/voice-sdk';
import VoiceAPI from './voiceAPIClient';
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
class TwilioVoiceClient extends EventTarget {
constructor() {
super();
this.device = null;
this.activeConnection = null;
this.initialized = false;
this.inboxId = null;
}
async initializeDevice(inboxId) {
this.destroyDevice();
const response = await VoiceAPI.getToken(inboxId);
const { token, account_id } = response || {};
if (!token) throw new Error('Invalid token');
this.device = new Device(token, {
allowIncomingWhileBusy: true,
disableAudioContextSounds: true,
appParams: { account_id },
});
this.device.removeAllListeners();
this.device.on('connect', conn => {
this.activeConnection = conn;
conn.on('disconnect', this.onDisconnect);
});
this.device.on('disconnect', this.onDisconnect);
this.device.on('tokenWillExpire', async () => {
const r = await VoiceAPI.getToken(this.inboxId);
if (r?.token) this.device.updateToken(r.token);
});
this.initialized = true;
this.inboxId = inboxId;
return this.device;
}
get hasActiveConnection() {
return !!this.activeConnection;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
}
this.activeConnection = null;
if (this.device) {
this.device.disconnectAll();
}
}
destroyDevice() {
if (this.device) {
this.device.destroy();
}
this.activeConnection = null;
this.device = null;
this.initialized = false;
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
const params = {
To: to,
is_agent: 'true',
conversation_id: conversationId,
};
const connection = await this.device.connect({ params });
this.activeConnection = connection;
connection.on('disconnect', this.onDisconnect);
return connection;
}
onDisconnect = () => {
this.activeConnection = null;
this.dispatchEvent(createCallDisconnectedEvent());
};
}
export default new TwilioVoiceClient();
@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from '../../ApiClient';
import ContactsAPI from '../../contacts';
class VoiceAPI extends ApiClient {
constructor() {
super('voice', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
initiateCall(contactId, inboxId) {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
joinConference({ conversationId, inboxId, callSid }) {
return axios
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
conversation_id: conversationId,
call_sid: callSid,
})
.then(r => r.data);
}
getToken(inboxId) {
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
return axios
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
.then(r => r.data);
}
}
export default new VoiceAPI();
+10
View File
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
@@ -171,10 +171,13 @@ const onPaste = e => {
const files = e.clipboardData?.files;
if (!files?.length) return;
Array.from(files).forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
// Filter valid files (non-zero size)
Array.from(files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
};
useEventListener(document, 'paste', onPaste);
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
@@ -0,0 +1,28 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -0,0 +1,184 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -676,11 +676,18 @@ function createEditorView() {
typingIndicator.stop();
emit('blur');
},
paste: (_view, event) => {
paste: (view, event) => {
if (props.disabled) return;
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
const { files } = event.clipboardData;
if (!files.length) return;
event.preventDefault();
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
// while ReplyBox filters and handles valid file attachments
const text = event.clipboardData.getData('text/plain');
if (text) {
view.dispatch(view.state.tr.insertText(text));
emitOnChange();
}
},
},
@@ -852,6 +859,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,6 +41,9 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -692,20 +692,20 @@ export default {
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) {
return;
}
if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
});
},
toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState;
@@ -0,0 +1,110 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
isJoining.value = true;
try {
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
const joinResponse = await VoiceAPI.joinConference({
conversationId,
inboxId,
callSid,
});
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
} finally {
isJoining.value = false;
}
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
const dismissCall = callSid => {
callsStore.dismissCall(callSid);
};
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
formattedCallDuration,
joinCall,
endCall,
rejectIncomingCall,
dismissCall,
};
}
@@ -140,6 +140,11 @@ export const FORMATTING = {
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
};
// Editor menu options for Full Editor
+28
View File
@@ -0,0 +1,28 @@
export default class Timer {
constructor(onTick = null) {
this.elapsed = 0;
this.intervalId = null;
this.onTick = onTick;
}
start() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.elapsed = 0;
this.intervalId = setInterval(() => {
this.elapsed += 1;
if (this.onTick) {
this.onTick(this.elapsed);
}
}, 1000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.elapsed = 0;
}
}
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Timer from '../Timer';
describe('Timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('constructor', () => {
it('initializes with elapsed 0 and no interval', () => {
const timer = new Timer();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('accepts an onTick callback', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
expect(timer.onTick).toBe(onTick);
});
});
describe('start', () => {
it('starts the timer and increments elapsed every second', () => {
const timer = new Timer();
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(1);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(2);
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(5);
});
it('calls onTick callback with elapsed value', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
timer.start();
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(1);
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(2);
expect(onTick).toHaveBeenCalledTimes(2);
});
it('resets elapsed to 0 when restarted', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(5);
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(2000);
expect(timer.elapsed).toBe(2);
});
it('clears previous interval when restarted', () => {
const timer = new Timer();
timer.start();
const firstIntervalId = timer.intervalId;
timer.start();
expect(timer.intervalId).not.toBe(firstIntervalId);
});
});
describe('stop', () => {
it('stops the timer and resets elapsed to 0', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(3);
timer.stop();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('prevents further increments after stopping', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(2000);
timer.stop();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(0);
});
it('handles stop when timer is not running', () => {
const timer = new Timer();
expect(() => timer.stop()).not.toThrow();
expect(timer.elapsed).toBe(0);
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'no-answer',
'canceled',
'missed',
'ended',
];
export const isInbound = direction => direction === 'inbound';
const isVoiceCallMessage = message => {
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
};
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
}
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (isNewCall) {
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
}
@@ -28,6 +28,7 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -275,6 +275,16 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -808,6 +808,35 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,5 +1,5 @@
<script>
import { defineAsyncComponent, ref } from 'vue';
import { defineAsyncComponent, ref, computed } from 'vue';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
@@ -16,10 +16,15 @@ const CommandBar = defineAsyncComponent(
() => import('./commands/commandbar.vue')
);
const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
export default {
components: {
@@ -30,6 +35,7 @@ export default {
UpgradePage,
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -37,6 +43,7 @@ export default {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
return {
uiSettings,
@@ -44,6 +51,8 @@ export default {
accountId,
upgradePageRef,
windowWidth,
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
};
},
data() {
@@ -151,6 +160,7 @@ export default {
@toggle="toggleMobileSidebar"
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -150,8 +150,6 @@ const derivedAttributes = computed(() =>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!attributes.length"
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
>
<template #header>
<BaseSettingsHeader
@@ -177,7 +175,7 @@ const derivedAttributes = computed(() =>
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div class="grid gap-3">
<div v-if="derivedAttributes.length" class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
@@ -187,6 +185,12 @@ const derivedAttributes = computed(() =>
@delete="handleDeleteAttribute"
/>
</div>
<p
v-else
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
>
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
</p>
</div>
</template>
<AddAttribute
@@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@@ -21,6 +28,10 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
props.inbox?.id
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -29,7 +40,19 @@ const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
});
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const originalTemplateValues = ref({
message: '',
templateButtonText: '',
templateLanguage: '',
});
const filterTypes = [
@@ -51,6 +74,59 @@ const labelOptions = computed(() =>
: []
);
const languageOptions = computed(() =>
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
);
const messagePreviewData = computed(() => ({
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
}));
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
icon: 'i-lucide-circle-check',
color: 'text-n-teal-11',
},
PENDING: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
icon: 'i-lucide-clock',
color: 'text-n-amber-11',
},
REJECTED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
icon: 'i-lucide-circle-x',
color: 'text-n-ruby-10',
},
};
// Handle template not found case
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
icon: 'i-lucide-alert-triangle',
color: 'text-n-ruby-10',
};
}
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
}
// Default case - no template exists
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
icon: 'i-lucide-stamp',
color: 'text-n-slate-11',
};
});
const initializeState = () => {
if (!props.inbox) return;
@@ -63,21 +139,63 @@ const initializeState = () => {
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
button_text: buttonText = 'Please rate us',
language = 'en',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.templateButtonText = buttonText;
state.templateLanguage = language;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
}
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
try {
templateLoading.value = true;
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
inboxId: props.inbox.id,
});
// Handle case where template doesn't exist
if (!response.template_exists && response.error === 'Template not found') {
templateStatus.value = {
template_exists: false,
error: 'TEMPLATE_NOT_FOUND',
};
} else {
templateStatus.value = response;
}
} catch (error) {
templateStatus.value = {
template_exists: false,
error: 'API_ERROR',
};
} finally {
templateLoading.value = false;
}
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -105,6 +223,49 @@ const removeLabel = label => {
}
};
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
original.message !== state.message ||
original.templateButtonText !== state.templateButtonText ||
original.templateLanguage !== state.templateLanguage
);
};
// Check if there's an existing template
const hasExistingTemplate = () => {
const { template_exists, error } = templateStatus.value || {};
return template_exists && !error;
};
// Check if we should create a template
const shouldCreateTemplate = () => {
// Create template if no existing template
if (!hasExistingTemplate()) {
return true;
}
// Create template if there are changes to template fields
return hasTemplateChanges();
};
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
const { template_name, template_id, template, status } =
templateStatus.value || {};
return {
name: template_name,
template_id,
language: template?.language || state.templateLanguage,
status,
};
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
@@ -115,31 +276,103 @@ const updateInbox = async attributes => {
return store.dispatch('inboxes/updateInbox', payload);
};
const saveSettings = async () => {
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
});
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
return response.template;
};
const performSave = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
try {
newTemplateData = await createTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
useAlert(errorMessage);
return;
}
}
const csatConfig = {
display_type: state.displayType,
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
csatConfig.template = templateConfig;
}
}
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
checkTemplateStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
) {
confirmDialog.value?.open();
return;
}
await performSave();
};
const handleConfirmTemplateUpdate = async () => {
// We will delete the template before creating the template
await performSave();
};
</script>
<template>
@@ -155,7 +388,9 @@ const saveSettings = async () => {
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -165,14 +400,97 @@ const saveSettings = async () => {
/>
</WithLabel>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
@@ -180,7 +498,7 @@ const saveSettings = async () => {
>
<div class="mb-4">
<span
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
@@ -217,7 +535,11 @@ const saveSettings = async () => {
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
{{
isWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
</p>
<div>
<NextButton
@@ -229,5 +551,11 @@ const saveSettings = async () => {
</div>
</div>
</SectionLayout>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
ref="confirmDialog"
@confirm="handleConfirmTemplateUpdate"
/>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
dialogRef.value?.close();
};
defineExpose({
dialogRef,
open: () => dialogRef.value?.open(),
close: () => dialogRef.value?.close(),
});
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -12,6 +12,10 @@ import {
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
@@ -299,7 +303,7 @@ const actions = {
}
},
addMessage({ commit }, message) {
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
@@ -308,10 +312,12 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
},
updateMessage({ commit }, message) {
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
},
deleteMessage: async function deleteLabels(
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
const state = {
allConversations: [],
@@ -24,6 +25,10 @@ const state = {
copilotAssistant: {},
};
const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
@@ -270,6 +275,36 @@ export const mutations = {
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
_state,
{ conversationId, callStatus }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
);
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
@@ -0,0 +1,159 @@
import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
content_type: 'text',
});
});
it('updates the last voice call message status', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
},
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
status: 'in-progress',
});
});
it('handles empty messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages).toEqual([]);
});
});
});
@@ -83,6 +83,14 @@ export const getters = {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -344,6 +352,14 @@ export const actions = {
throw new Error(error);
}
},
createCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
return response.data;
},
getCSATTemplateStatus: async (_, { inboxId }) => {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
};
export const mutations = {
@@ -51,6 +51,8 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
+59
View File
@@ -0,0 +1,59 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
}),
getters: {
activeCall: state => state.calls.find(call => call.isActive) || null,
hasActiveCall: state => state.calls.some(call => call.isActive),
incomingCalls: state => state.calls.filter(call => !call.isActive),
hasIncomingCall: state => state.calls.some(call => !call.isActive),
},
actions: {
handleCallStatusChanged({ callSid, status }) {
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
}
},
addCall(callData) {
if (!callData?.callSid) return;
const exists = this.calls.some(call => call.callSid === callData.callSid);
if (exists) return;
this.calls.push({
...callData,
isActive: false,
});
},
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
setCallActive(callSid) {
this.calls = this.calls.map(call => ({
...call,
isActive: call.callSid === callSid,
}));
},
clearActiveCall() {
TwilioVoiceClient.endClientCall();
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
},
});
@@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
};
+19 -5
View File
@@ -322,12 +322,21 @@ class Message < ApplicationRecord
end
def update_waiting_since
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -341,6 +350,11 @@ class Message < ApplicationRecord
sender.is_a?(User)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
+1 -1
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :string
# url :text
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
+11
View File
@@ -95,6 +95,7 @@ class MailPresenter < SimpleDelegator
content_type: content_type,
date: date,
from: from,
headers: headers_data,
html_content: html_content,
in_reply_to: in_reply_to,
message_id: message_id,
@@ -136,6 +137,16 @@ class MailPresenter < SimpleDelegator
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
end
def headers_data
headers = {
'x-original-from' => @mail['X-Original-From']&.value,
'x-original-sender' => @mail['X-Original-Sender']&.value,
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
}.compact
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
+61 -1
View File
@@ -4,7 +4,9 @@ class CsatSurveyService
def perform
return unless should_send_csat_survey?
if within_messaging_window?
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
create_csat_not_sent_activity_message
@@ -35,6 +37,64 @@ class CsatSurveyService
conversation.can_reply?
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
def template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
status_result[:success] && status_result[:template][:status] == 'APPROVED'
rescue StandardError => e
Rails.logger.error "Error checking CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
message = build_csat_message
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def build_template_info(template_name, template_config)
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
}
end
def build_csat_message
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content_type: :input_csat
)
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
def build_content_variables(template)
case template['template_type']
when 'text', 'quick_reply'
convert_text_template(template_params) # Text and quick reply templates use body variables
when 'text', 'quick_reply', 'call_to_action'
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
when 'media'
convert_media_template(template_params)
else
@@ -63,6 +63,8 @@ class Twilio::TemplateSyncService
'media'
elsif template_types.include?('twilio/quick-reply')
'quick_reply'
elsif template_types.include?('twilio/call-to-action')
'call_to_action'
elsif template_types.include?('twilio/catalog')
'catalog'
else
@@ -107,6 +109,8 @@ class Twilio::TemplateSyncService
template_types['twilio/media']['body']
elsif template_types['twilio/quick-reply']
template_types['twilio/quick-reply']['body']
elsif template_types['twilio/call-to-action']
template_types['twilio/call-to-action']['body']
elsif template_types['twilio/catalog']
template_types['twilio/catalog']['body']
else
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.8.0'
version: '4.9.1'
development:
<<: *shared
+1 -1
View File
@@ -172,7 +172,7 @@
- name: channel_voice
display_name: Voice Channel
enabled: false
chatwoot_internal: true
premium: true
- name: notion_integration
display_name: Notion Integration
enabled: false
+2 -1
View File
@@ -5,7 +5,8 @@
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('alfred', redis: redis, warning: true)
end
+2
View File
@@ -62,6 +62,8 @@ en:
failed: Signup failed
assignment_policy:
not_found: Assignment policy not found
attachments:
invalid: Invalid attachment
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
@@ -0,0 +1,9 @@
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
def up
change_column :webhooks, :url, :text
end
def down
change_column :webhooks, :url, :string
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
create_table "webhooks", force: :cascade do |t|
t.integer "account_id"
t.integer "inbox_id"
t.string "url"
t.text "url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
@@ -16,9 +16,17 @@ module Enterprise::Account::PlanUsageAndLimits
end
def increment_response_usage
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
save
increment_sql = <<~SQL.squish
custom_attributes = jsonb_set(
custom_attributes,
'{#{CAPTAIN_RESPONSES_USAGE}}',
to_jsonb(COALESCE((custom_attributes->>'#{CAPTAIN_RESPONSES_USAGE}')::int, 0) + 1),
true
)
SQL
updated = self.class.where(id: id).update_all(increment_sql)
reload if updated.positive?
end
def reset_response_usage
@@ -18,6 +18,7 @@ class Enterprise::Billing::HandleStripeEventService
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
# Additional features available starting with the Business plan
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.8.0",
"version": "4.9.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -49,6 +49,7 @@
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/vue-table": "^8.20.5",
"@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.5.8",
"@vuelidate/core": "^2.0.3",
+37
View File
@@ -67,6 +67,9 @@ importers:
'@tanstack/vue-table':
specifier: ^8.20.5
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@twilio/voice-sdk':
specifier: ^2.12.4
version: 2.17.0
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
@@ -1283,9 +1286,19 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
'@twilio/voice-errors@1.7.0':
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
'@twilio/voice-sdk@2.17.0':
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
engines: {node: '>= 12'}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/events@3.0.3':
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
'@types/flexsearch@0.7.6':
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
@@ -2399,6 +2412,10 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expect-type@1.1.0:
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
engines: {node: '>=12.0.0'}
@@ -3119,6 +3136,10 @@ packages:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@@ -5750,8 +5771,20 @@ snapshots:
'@tootallnate/once@2.0.0': {}
'@twilio/voice-errors@1.7.0': {}
'@twilio/voice-sdk@2.17.0':
dependencies:
'@twilio/voice-errors': 1.7.0
'@types/events': 3.0.3
events: 3.3.0
loglevel: 1.9.2
tslib: 2.8.1
'@types/estree@1.0.8': {}
'@types/events@3.0.3': {}
'@types/flexsearch@0.7.6': {}
'@types/fs-extra@9.0.13':
@@ -7137,6 +7170,8 @@ snapshots:
eventemitter3@5.0.1: {}
events@3.3.0: {}
expect-type@1.1.0: {}
extend-shallow@2.0.1:
@@ -7966,6 +8001,8 @@ snapshots:
strip-ansi: 7.1.0
wrap-ansi: 9.0.0
loglevel@1.9.2: {}
loupe@3.1.3: {}
lower-case@2.0.2:
@@ -141,21 +141,14 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
end
it 'Saves file in the automation actions to send an attachments' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
expect(account.automation_rules.count).to eq(0)
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file }
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
params[:actions] = [
{
'action_name': :send_message,
@@ -163,7 +156,7 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
},
{
'action_name': :send_attachment,
'action_params': [blob['blob_id']]
'action_params': [blob.signed_id]
}
]
@@ -177,29 +170,25 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
end
it 'Saves files in the automation actions to send multiple attachments' do
file_1 = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
file_2 = fixture_file_upload(Rails.root.join('spec/assets/sample.png'), 'image/png')
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file_1 }
blob_1 = response.parsed_body
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file_2 }
blob_2 = response.parsed_body
blob_1 = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
blob_2 = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/sample.png').open,
filename: 'sample.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob_1['blob_id']]
'action_params': [blob_1.signed_id]
},
{
'action_name': :send_attachment,
'action_params': [blob_2['blob_id']]
'action_params': [blob_2.signed_id]
}
]
@@ -210,6 +199,46 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
automation_rule = account.automation_rules.first
expect(automation_rule.files.count).to eq(2)
end
it 'returns error for invalid attachment blob_id' do
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': ['invalid_blob_id']
}
]
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'stores the original blob_id in action_params after create' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
automation_rule = account.automation_rules.first
attachment_action = automation_rule.actions.find { |a| a['action_name'] == 'send_attachment' }
expect(attachment_action['action_params'].first).to be_a(Integer)
expect(attachment_action['action_params'].first).to eq(automation_rule.files.first.blob_id)
end
end
end
@@ -328,6 +357,68 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
expect(body[:payload][:active]).to be(false)
expect(automation_rule.reload.active).to be(false)
end
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
automation_rule.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
automation_rule.files.attach(blob)
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.id]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:success)
end
it 'returns error for invalid blob_id on update' do
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [999_999]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'allows adding new attachment on update with signed blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:success)
expect(automation_rule.reload.files.count).to eq(1)
end
end
end
@@ -127,18 +127,11 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
end
it 'Saves file in the macros actions to send an attachments' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file }
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
@@ -147,7 +140,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
},
{
'action_name': :send_attachment,
'action_params': [blob['blob_id']]
'action_params': [blob.signed_id]
}
]
@@ -159,6 +152,46 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(macro.files.presence).to be_truthy
expect(macro.files.count).to eq(1)
end
it 'returns error for invalid attachment blob_id' do
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': ['invalid_blob_id']
}
]
post "/api/v1/accounts/#{account.id}/macros",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'stores the original blob_id in action_params after create' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
post "/api/v1/accounts/#{account.id}/macros",
headers: administrator.create_new_auth_token,
params: params
macro = account.macros.last
attachment_action = macro.actions.find { |a| a['action_name'] == 'send_attachment' }
expect(attachment_action['action_params'].first).to be_a(Integer)
expect(attachment_action['action_params'].first).to eq(macro.files.first.blob_id)
end
end
end
@@ -202,6 +235,47 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(response).to have_http_status(:unauthorized)
expect(json_response['error']).to eq('You are not authorized to do this action')
end
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
macro.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
macro.files.attach(blob)
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.id] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
end
it 'returns error for invalid blob_id on update' do
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [999_999] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'allows adding new attachment on update with signed blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.signed_id] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
expect(macro.reload.files.count).to eq(1)
end
end
end
@@ -18,7 +18,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
blob = response.parsed_body
expect(blob['errors']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end
@@ -53,7 +52,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
blob = response.parsed_body
expect(blob['error']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end
+40
View File
@@ -78,6 +78,46 @@ RSpec.describe Account, type: :model do
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 1
end
it 'handles concurrent increments without losing updates' do
# Simulate concurrent calls to increment_response_usage
# This tests that the atomic SQL update prevents race conditions
concurrent_calls = 50
# Use a barrier to ensure all threads start at the same time
# This maximizes the chance of overlapping reads/writes
ready = Concurrent::CountDownLatch.new(concurrent_calls)
start = Concurrent::CountDownLatch.new(1)
threads = concurrent_calls.times.map do
Thread.new do
# Reload account in each thread to simulate separate workers
thread_account = Account.find(account.id)
# Signal ready and wait for all threads to be ready
ready.count_down
start.wait
# Now all threads will execute simultaneously
thread_account.increment_response_usage
end
end
# Wait for all threads to be ready
ready.wait
# Release all threads at once
start.count_down
# Wait for all threads to complete
threads.each(&:join)
# Verify all increments were captured without lost updates
expect(account.reload.custom_attributes['captain_responses_usage']).to eq concurrent_calls
responses_limits = account.usage_limits[:captain][:responses]
expect(responses_limits[:consumed]).to eq concurrent_calls
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - concurrent_calls
end
it 'reseting responses limits updates usage_limits' do
account.custom_attributes['captain_responses_usage'] = 30
account.save!
+2 -1
View File
@@ -14,8 +14,9 @@ RSpec.describe Message do
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
# Captain::Assistant responses clear waiting_since (like AgentBot)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
expect(conversation.waiting_since).to be_nil
create(:message, message_type: :outgoing, conversation: conversation)
+3 -3
View File
@@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content
to auto_reply]
end
context 'with reply uuid present' do
@@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+65
View File
@@ -978,5 +978,70 @@ RSpec.describe Conversation do
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
context 'when AgentBot responds between customer messages' do
let(:agent_bot) { create(:agent_bot, account: account) }
def create_bot_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: agent_bot,
created_at: created_at)
end
message
end
it 'calculates reply time from the most recent customer message after bot response' do
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 5.hours.ago)
# Bot responds
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after bot response) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies - should create reply_time event from customer message 2
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
it 'handles multiple bot responses before customer messages again' do
# Initial conversation: customer message -> agent first reply
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 6.hours.ago)
# Bot responds multiple times
create_bot_message(conversation, created_at: 5.hours.ago)
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after multiple bot responses) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
end
end
end
+57
View File
@@ -300,6 +300,63 @@ RSpec.describe Message do
expect(conversation.waiting_since).to eq old_waiting_since
end
context 'when bot has responded to the conversation' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
before do
# Create initial customer message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 2.hours.ago)
conversation.update(waiting_since: 2.hours.ago)
# Bot responds
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 1.hour.ago)
end
it 'resets waiting_since when customer sends a new message after bot response' do
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'does not reset waiting_since if last response was from human agent' do
# Human agent responds (clears waiting_since)
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent)
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends new message
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'clears waiting_since when bot responds' do
# After the bot response in before block, waiting_since should already be cleared
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends another message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 30.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
# Another bot response should clear it again
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 15.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
end
end
context 'with webhook_data' do
+34
View File
@@ -41,6 +41,7 @@ RSpec.describe MailPresenter do
:content_type,
:date,
:from,
:headers,
:html_content,
:in_reply_to,
:message_id,
@@ -60,6 +61,39 @@ RSpec.describe MailPresenter do
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'includes forwarded headers in serialized_data' do
mail_with_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['X-Original-From'] = 'Original <original@example.com>'
header['X-Original-Sender'] = 'original@example.com'
header['X-Forwarded-For'] = 'forwarder@example.com'
end
data = described_class.new(mail_with_headers).serialized_data
expect(data[:headers]).to eq(
'x-original-from' => 'Original <original@example.com>',
'x-original-sender' => 'original@example.com',
'x-forwarded-for' => 'forwarder@example.com'
)
end
it 'returns nil headers when forwarding headers are missing' do
mail_without_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
data = described_class.new(mail_without_headers).serialized_data
expect(data[:headers]).to be_nil
end
it 'give email from in downcased format' do
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
end
+267 -1
View File
@@ -3,7 +3,9 @@ require 'rails_helper'
describe CsatSurveyService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
let(:service) { described_class.new(conversation: conversation) }
describe '#perform' do
@@ -87,5 +89,269 @@ describe CsatSurveyService do
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
end
context 'when it is a WhatsApp channel' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
let(:whatsapp_contact) { create(:contact, account: account) }
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
let(:whatsapp_conversation) do
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
end
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
end
context 'when template is available and approved' do
before do
setup_approved_template('customer_survey_template')
end
it 'sends WhatsApp template survey instead of regular survey' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: 'customer_survey_template',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'button',
sub_type: 'url',
index: '0',
parameters: array_including(
hash_including(type: 'text', text: whatsapp_conversation.uuid)
)
)
)
),
instance_of(Message)
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
it 'updates message with returned message ID' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to eq('template_message_id_123')
end
it 'builds correct template info with default template name' do
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
allow(mock_provider_service).to receive(:get_template_status)
.with(expected_template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: expected_template_name,
lang_code: 'en'
),
anything
)
end
it 'builds CSAT message with correct attributes' do
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.account).to eq(account)
expect(csat_message.inbox).to eq(whatsapp_inbox)
expect(csat_message.message_type).to eq('outgoing')
expect(csat_message.content).to eq('Please rate your experience')
expect(csat_message.content_type).to eq('input_csat')
end
it 'uses default message when not configured' do
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation')
end
end
context 'when template is not available or not approved' do
it 'falls back to regular survey when template is pending' do
setup_template_with_status('pending_template', 'PENDING')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template is rejected' do
setup_template_with_status('pending_template', 'REJECTED')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template API call fails' do
allow(mock_provider_service).to receive(:get_template_status)
.with('pending_template')
.and_return({ success: false, error: 'Template not found' })
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template status check raises error' do
allow(mock_provider_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when no template is configured' do
it 'falls back to regular survey' do
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when template sending fails' do
before do
setup_approved_template('working_template', {
'template' => { 'name' => 'working_template' },
'message' => 'Rate us'
})
end
it 'handles template sending errors gracefully' do
mock_template_send_failure('Template send failed')
expect { whatsapp_service.perform }.not_to raise_error
# Should still create the CSAT message even if sending fails
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
it 'does not update message when send_template returns nil' do
mock_template_send_with_no_id
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
end
context 'when outside messaging window' do
before do
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
end
it 'sends template survey even when outside messaging window if template is approved' do
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
# No activity message should be created when template is successfully sent
end
it 'creates activity message when template is not available and outside window' do
whatsapp_service.perform
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
whatsapp_conversation,
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
end
end
private
def setup_approved_template(template_name, config = nil)
template_config = config || {
'template' => {
'name' => template_name,
'language' => 'en'
},
'message' => 'Please rate your experience'
}
whatsapp_inbox.update(csat_config: template_config)
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
end
def setup_template_with_status(template_name, status)
whatsapp_inbox.update(csat_config: {
'template' => { 'name' => template_name }
})
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: status } })
end
def mock_successful_template_send(message_id)
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
message_id
end
end
def mock_template_send_failure(error_message = 'Template send failed')
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
raise StandardError, error_message
end
end
def mock_template_send_with_no_id
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
nil
end
end
end
@@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Instagram' do
@@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Line' do
@@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::TwilioSms' do
@@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
let(:call_to_action_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX444555666',
friendly_name: 'payment_reminder',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/call-to-action' => {
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
'actions' => [
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
]
}
}
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
before do
allow(twilio_channel).to receive(:send).and_call_original
@@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do
twilio_channel.reload
expect(twilio_channel.content_templates).to be_present
expect(twilio_channel.content_templates['templates']).to be_an(Array)
expect(twilio_channel.content_templates['templates'].size).to eq(4)
expect(twilio_channel.content_templates['templates'].size).to eq(5)
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
end
end
@@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
it 'correctly formats call-to-action templates with variables' do
sync_service.call
twilio_channel.reload
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'payment_reminder'
end
expect(call_to_action_data).to include(
'content_sid' => 'HX444555666',
'friendly_name' => 'payment_reminder',
'language' => 'en',
'status' => 'approved',
'template_type' => 'call_to_action',
'media_type' => nil,
'variables' => {},
'category' => 'utility'
)
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
expect(call_to_action_data['body']).to eq(expected_body)
expect(call_to_action_data['body']).to match(/{{date}}/)
end
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,