Compare commits

..
Author SHA1 Message Date
Pranav ac02a60707 Merge commit '5ba87a3d27a2284236d598d871eb46bec310f18d' into change-the-order-of-after_create_commit 2025-06-27 16:42:09 -07:00
Pranav 5ba87a3d27 disable ddl transaction on concurrent add_index 2025-06-27 16:31:22 -07:00
Pranav f73e328709 make it work 2025-06-27 16:24:36 -07:00
Pranav 5eb94fc545 Change the order of after create_commit 2025-06-27 15:51:30 -07:00
PranavandGitHub eea1ab3002 fix: Add composite index on messages for csat_metrics API performance (#11831)
This PR adds a composite index (:account_id, :content_type, :created_at)
on the table messages.

This index is added as a temporary fix for performance issues in the
CSAT responses controller where we query messages with account_id,
content_type and created_at. The current implementation
(account.message.input_csat.count) times out with millions of messages.

TODO: Create a dedicated csat_survey table and add entries when surveys
are sent, then query this table instead of the entire messages table for
better performance.
2025-06-27 15:48:04 -07:00
b7f3f72b9c fix: Reply time calculation for re-opened conversations (#11787)
This PR fixes the reply time calculation for reopened conversations.
Previously, when a customer sent a message to reopen a resolved
conversation, the reply time metric would be calculated incorrectly
because the `waiting_since` timestamp was not properly set before the
reply event was dispatched. This would create a case where you'd have
reporting events like the following

```
[[33955732, "reply_time", 19.0],
 [33955847, "reply_time", 24.0],
 [33955666, "reply_time", 89.0],
 [33955530, "conversation_bot_handoff", 4.0],
 [33955567, "first_response", 42.0],
 [33955745, "reply_time", 21.0],
 [33955934, "reply_time", 49.0],
 [33955906, "reply_time", 121.0],
 [33987938, "conversation_resolved", 26285.0],
 [35571005, "reply_time", 985492.0]]
```
Note the `reply_time` after `conversation_resolved`

The fix ensures that `waiting_since` is correctly updated when
conversations are reopened, either through incoming messages or manual
status changes, resulting in accurate reply time metrics that measure
only the time from the customer's new message to the agent's response.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

The changes have been tested with comprehensive specs that verify:

1. **Reply time calculation after conversation reopening** - Ensures
correct timestamps are used when calculating reply times for reopened
conversations
2. **Waiting since updates on status changes** - Verifies that
`waiting_since` is properly set when conversation status changes from
resolved to open
3. **Test the happy path** - Happy path is tested to ensure the
`reply_time` and `first_response_time` is correctly calculated

Test instructions:
1. Create a conversation with the last message from a customer and
resolve it
2. Have an agent reopen it and reply to it
4. When an agent replies, verify that the agent reply_time event is not
created for this message

To fix any existing data, I've written a small script:
https://gist.github.com/scmmishra/fdf458863f2d971978327bbfd5232d0c

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-27 10:48:07 +05:30
b26862e3d8 feat: notion OAuth setup (#11765)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-26 19:16:06 +05:30
811eb66615 feat: Add support for image files in Captain (#11730)
# Pull Request Template

## Linear links:
-
https://linear.app/chatwoot/issue/CW-4479/if-image-is-sent-by-the-customer-send-it-to-openai

## Description

This pull request adds “Captain image support” to Chatwoot. It
introduces multimodal message handling so that when a customer sends an
image, Captain can forward the file to OpenAI’s vision endpoint,
generate a caption/analysis
## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

<img width="891" alt="image"
src="https://github.com/user-attachments/assets/c7cc98ed-cc44-4865-a53a-83d129e2fe2c"
/>



## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-06-25 19:16:09 -07:00
PranavandGitHub 257cd07ee6 fix: Check if there are any subscriptions before we create a default subscription (#11813)
Avoid creating duplicate subscriptions.
2025-06-25 18:53:36 -07:00
b7f2c151bf feat: Voice channel creation Flow (#11775)
This PR introduces a new channel type for voice conversations.

ref: #11481 

## Changes

- Add database migration for channel_voice table with phone_number and
provider_config
- Create Channel::Voice model with E.164 phone number validation and
Twilio config validation
- Add voice channel association to Account model
- Extend inbox helpers and types to support voice channels
- Add voice channel setup UI with Twilio configuration form
- Include voice channel in channel factory and list components
- Add API routes and store actions for voice channel creation
- Add comprehensive translations for voice channel management

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-06-25 14:21:03 -07:00
97efd36bc5 fix: Add missing CSAT URL in email reply templates (#11808)
As part of PR [#11622](https://github.com/chatwoot/chatwoot/pull/11622),
we intentionally hide CSAT survey URLs from agents in the dashboard to
avoid exposing those links internally. However, email replies being
external communications to contacts should still include the survey
URLs.

We missed handling that specific case for email channels. All other
external channels like WhatsApp, Instagram, Telegram, etc., were already
working correctly and continue to include the survey URLs as intended.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-06-26 01:19:58 +05:30
PranavandGitHub a0bf6055fc fix: Upgrade letter_opener to fix cannot load such file -- kconv error (#11809)
The email jobs were failing in local development due to a cannot load
such file -- kconv error. This was caused by a removed dependency in the
latest version of the letter_opener gem. Upgrading to the latest version
resolves the issue.
2025-06-25 10:45:11 -07:00
c42dd8a23e feat: captain should be able to access private notes (#11768)
# Pull Request Template

## Linear task: 

https://linear.app/chatwoot/issue/CW-4482/captain-should-be-able-to-access-private-notes-only-on-copilot

## Description

Captain should be able to access private notes (only on copilot)

## Type of change

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

## How Has This Been Tested?


![image](https://github.com/user-attachments/assets/b25cf81f-85eb-4adb-a1eb-57e1156b9b9e)


![image](https://github.com/user-attachments/assets/20051b31-cbce-41d9-84d9-13bc71687323)


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-24 19:00:20 -07:00
cc8866a84e feat: Sync Popular Articles locale with widget locale (#11754)
# Pull Request Template

## Description

This PR includes the following improvements:
* **Popular Articles Locale Selection based on Widget Locale**
  * Implements priority-based locale matching:
    * Exact locale match (e.g., "fr" === "fr")
    * Base language match (e.g., "fr" when selected is "fr_CA")
    * Variant match (e.g., "fr_BE" when selected is "fr")
* Removes default locale fallback - if no locale match is found, popular
articles section is hidden.
    
* Fixed **API** filter issue where the locale parameter was previously
ignored

* Hides Popular Articles section completely when no locale match is
found and Only shows relevant articles in the user's language


* **RTL Direction Handling Improvements**
* Now directly reads the `lang` attribute from HTML element `<html
lang="en">` instead of relying on `.locale-switcher` and sets direction
attribute based on language.

* Adds `data-dir-applied` attribute to prevent overlapping direction
settings between global helpers and components (eg case: Insert article
in editor dashboard)

* Update `IframeLoader.vue` to Composition API and improve the **dir**
logic

Fixes 
1.
[CW-4505](https://linear.app/chatwoot/issue/CW-4505/popular-articles-not-displayed-based-on-user-locale-in-live-chat),
https://github.com/chatwoot/chatwoot/issues/11745
2. RTL direction is not working in widget article view after merging
this PR https://github.com/chatwoot/chatwoot/pull/11692

## Type of change

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

## How Has This Been Tested?

### Loom video

**Popular Articles**

https://www.loom.com/share/7cecbaaa77eb48e19263398b6ba8ddef?sid=a2452b8e-7d7e-46a3-b5c8-aed5ab5bc801

**RTL improvements**

https://www.loom.com/share/3ccad77174a0412097e802641df5f3e0?sid=e10ac57f-5c49-4084-84d3-5ad58aee54fa

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-24 19:52:44 +05:30
Sivin VargheseandGitHub 92c51a10de chore: Update captain FAQ bulk action UI (#11780) 2025-06-24 16:33:42 +05:30
Sivin VargheseandGitHub 9edfb1e902 fix: Disable push notifications (#11786)
# Pull Request Template

## Description

Fixes
[CW-4512](https://linear.app/chatwoot/issue/CW-4512/cant-turn-off-push-notification-toggle)
https://github.com/chatwoot/chatwoot/issues/11760

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-06-24 13:31:39 +05:30
Sivin VargheseandGitHub be6bc88f80 fix: Translation issue in reports table headers on reload (#11793)
# Pull Request Template

## Description

This PR fixes the translation inconsistency in the reports pages, where
table-column headers reverted to English after a page reload.

**Cause**
The components defined the columns array statically, so header labels
were translated only once during component creation. On reload, the
table showed the default system language (English) until the user’s
locale finished loading.

**Solution**
Replaced the static columns array with a computed property and passed it
to `Tanstack useVueTable` via a getter. This makes the headers reactive,
ensuring they automatically update whenever the locale changes and
remain translated after every reload.

Fixes
https://linear.app/chatwoot/issue/CW-4539/translation-issue-in-reports-page-table-header-on-reload

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-06-24 12:38:05 +05:30
92 changed files with 1722 additions and 752 deletions
-8
View File
@@ -31,14 +31,6 @@ gem 'haikunator'
gem 'liquid'
# Parse Markdown to HTML
gem 'commonmarker'
# Convert PDF pages to images for content extraction
gem 'pdftoimage'
# Google Vision API for text extraction from images
gem 'google-cloud-vision'
# Tesseract OCR fallback when Vision API not available
gem 'rtesseract'
# Image manipulation for PDF page batching
gem 'mini_magick'
# Validate Data against JSON Schema
gem 'json_schemer'
# used in swagger build
+17 -33
View File
@@ -172,6 +172,8 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -275,16 +277,14 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gapic-common (1.0.0)
gapic-common (0.20.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
google-protobuf (>= 3.25, < 5.a)
googleapis-common-protos (~> 1.6)
googleapis-common-protos-types (~> 1.15)
googleauth (~> 1.12)
grpc (~> 1.66)
google-protobuf (~> 3.14)
googleapis-common-protos (>= 1.3.12, < 2.a)
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -329,13 +329,6 @@ GEM
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-vision (2.0.2)
google-cloud-core (~> 1.6)
google-cloud-vision-v1 (~> 1.2)
google-cloud-vision-v1 (1.3.0)
gapic-common (~> 1.0)
google-cloud-errors (~> 1.0)
google-logging-utils (0.2.0)
google-protobuf (3.25.7)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
@@ -343,10 +336,9 @@ GEM
grpc (~> 1.41)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
googleauth (1.14.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@@ -391,7 +383,6 @@ GEM
httpclient (2.8.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
iconv (1.1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
@@ -444,10 +435,12 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2)
launchy (3.1.1)
addressable (~> 2.8)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -561,9 +554,6 @@ GEM
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pdftoimage (0.2.1)
iconv (~> 1.0)
shellwords (~> 0.2.2)
pg (1.5.3)
pg_search (2.3.6)
activerecord (>= 5.2)
@@ -577,7 +567,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
@@ -686,7 +676,6 @@ GEM
rspec-support (3.13.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rtesseract (3.1.4)
rubocop (1.75.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -762,7 +751,6 @@ GEM
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shellwords (0.2.2)
shopify_api (14.8.0)
activesupport
concurrent-ruby
@@ -943,7 +931,6 @@ DEPENDENCIES
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage (>= 1.48.0)
google-cloud-translate-v3 (>= 0.7.0)
google-cloud-vision
groupdate
grpc
haikunator
@@ -967,7 +954,6 @@ DEPENDENCIES
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.3)
mini_magick
mock_redis
neighbor
net-smtp (~> 0.3.4)
@@ -977,7 +963,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pdftoimage
pg
pg_search
pgvector
@@ -997,7 +982,6 @@ DEPENDENCIES
reverse_markdown
rspec-rails (>= 6.1.5)
rspec_junit_formatter
rtesseract
rubocop
rubocop-factory_bot
rubocop-performance
@@ -1040,4 +1024,4 @@ RUBY VERSION
ruby 3.4.4p34
BUNDLED WITH
2.6.7
2.5.16
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
@@ -0,0 +1,14 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -0,0 +1,21 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -0,0 +1,21 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
@@ -0,0 +1,36 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
@@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -1,51 +1,21 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
modelValue: { type: [String, Number], default: '' },
type: { type: String, default: 'text' },
customInputClass: { type: [String, Object, Array], default: '' },
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
min: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -56,6 +26,10 @@ const emit = defineEmits([
'enter',
]);
// Generate a unique ID per component instance when `id` prop is not provided.
const { uid } = getCurrentInstance();
const uniqueId = computed(() => props.id || `input-${uid}`);
const isFocused = ref(false);
const inputRef = ref(null);
@@ -111,7 +85,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
:for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
@@ -119,7 +93,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:id="uniqueId"
ref="inputRef"
:value="modelValue"
:class="[
@@ -17,7 +17,7 @@ export default {
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
: ''
);
</script>
@@ -41,6 +41,10 @@ export default {
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -50,6 +54,7 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
},
@@ -125,6 +125,10 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
@@ -142,5 +146,6 @@ export const useInbox = () => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
};
};
+10
View File
@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
};
const INBOX_ICON_MAP_FILL = {
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -328,6 +328,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
"CAPTAIN": {
@@ -537,6 +545,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -157,6 +157,13 @@ const bulkCheckbox = computed({
},
});
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
@@ -270,7 +277,11 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
bulkSelectionState.hasSelected,
}"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
@@ -306,13 +317,18 @@ onMounted(() => {
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
class="flex items-center gap-3"
>
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
@@ -322,17 +338,23 @@ onMounted(() => {
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-2">
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
slate
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
slate
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
@@ -1,6 +1,7 @@
<script setup>
import IframeLoader from 'shared/components/IframeLoader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
url: {
@@ -11,6 +12,8 @@ defineProps({
const emit = defineEmits(['back', 'insert']);
const isRTL = useMapGetter('accounts/isRTL');
const onBack = e => {
e.stopPropagation();
emit('back');
@@ -35,7 +38,7 @@ const onInsert = e => {
</div>
<div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0">
<IframeLoader :url="url" />
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
</div>
</div>
@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
facebook: Facebook,
@@ -22,6 +23,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
voice: Voice,
};
export default defineComponent({
@@ -36,6 +36,7 @@ export default {
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
},
...mapGetters({
@@ -0,0 +1,184 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { isPhoneE164 } from 'shared/helpers/Validators';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import PageHeader from '../../SettingsSubPageHeader.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const state = reactive({
phoneNumber: '',
accountSid: '',
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
const validationRules = {
phoneNumber: { required, isPhoneE164 },
accountSid: { required },
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formErrors = computed(() => ({
phoneNumber: v$.value.phoneNumber?.$error
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
: '',
accountSid: v$.value.accountSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
: '',
authToken: v$.value.authToken?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
: '',
apiKeySid: v$.value.apiKeySid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
: '',
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
const config = {
account_sid: state.accountSid,
auth_token: state.authToken,
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
async function createChannel() {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
try {
const channel = await store.dispatch('inboxes/createVoiceChannel', {
name: `Voice (${state.phoneNumber})`,
voice: {
phone_number: state.phoneNumber,
provider: 'twilio',
provider_config: getProviderConfig(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: channel.id },
});
} catch (error) {
useAlert(
error.response?.data?.message ||
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
}
}
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form
class="flex flex-col gap-4 flex-wrap mx-0"
@submit.prevent="createChannel"
>
<Input
v-model="state.phoneNumber"
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
:message="formErrors.phoneNumber"
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
@blur="v$.phoneNumber?.$touch"
/>
<Input
v-model="state.accountSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
:message="formErrors.accountSid"
:message-type="formErrors.accountSid ? 'error' : 'info'"
@blur="v$.accountSid?.$touch"
/>
<Input
v-model="state.authToken"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
:message="formErrors.authToken"
:message-type="formErrors.authToken ? 'error' : 'info'"
@blur="v$.authToken?.$touch"
/>
<Input
v-model="state.apiKeySid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
:message="formErrors.apiKeySid"
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
@blur="v$.apiKeySid?.$touch"
/>
<Input
v-model="state.apiKeySecret"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
:message="formErrors.apiKeySecret"
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
:disabled="isSubmitDisabled"
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
/>
</div>
</form>
</div>
</template>
@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -0,0 +1,80 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const { t } = useI18n();
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'notion');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return '';
});
const authorize = async () => {
const response = await notionClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
};
const initializeNotionIntegration = async () => {
await store.dispatch('integrations/get', 'notion');
integrationLoaded.value = true;
};
onMounted(() => {
initializeNotionIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'),
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'),
}"
>
<template #action>
<ButtonNext
faded
blue
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="authorize"
/>
</template>
</Integration>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
export default {
@@ -90,6 +91,15 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'notion',
name: 'settings_integrations_notion',
component: Notion,
meta: {
permissions: ['administrator'],
},
props: route => ({ code: route.query.code }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
@@ -75,10 +75,34 @@ export default {
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
onRequestPermissions(value) {
if (value) {
// Enable / re-enable push notifications
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
} else {
// Disable push notifications
this.disablePushPermissions();
}
},
disablePushPermissions() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (subscription) {
return subscription.unsubscribe();
}
return null;
})
.finally(() => {
this.hasEnabledPushPermissions = false;
})
.catch(() => {
// error
})
);
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
cellProps.getValue()
);
const columns = [
const columns = computed(() => [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
@@ -90,7 +90,7 @@ const columns = [
width: 200,
cell: defaulSpanRender,
}),
];
]);
const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -142,7 +142,9 @@ const table = useVueTable({
get data() {
return tableData.value;
},
columns,
get columns() {
return columns.value;
},
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -220,6 +198,12 @@ export const actions = {
throw new Error(error);
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
// - createWebsiteChannel
// - createTwilioChannel
// - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
@@ -0,0 +1,52 @@
import * as types from '../../mutation-types';
import InboxesAPI from '../../../api/inboxes';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const channelActions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await InboxesAPI.create({
name: params.name,
channel: { ...params.voice, type: 'voice' },
});
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
};
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
describe('#createVoiceChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
await actions.createVoiceChannel({ commit }, inboxList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.ADD_INBOXES, inboxList[0]],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
+7 -4
View File
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
},
setDirectionAttribute: () => {
const portalElement = document.getElementById('portal');
if (!portalElement) return;
const htmlElement = document.querySelector('html');
// If direction is already applied through props, do not apply again (iframe case)
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
if (!htmlElement || hasDirApplied) return;
const locale = document.querySelector('.locale-switcher')?.value;
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
const localeFromHtml = htmlElement.lang;
htmlElement.dir =
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
@@ -1,64 +1,56 @@
<script>
<script setup>
import { ref, useTemplateRef, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
export default {
name: 'IframeLoader',
components: {
ArticleSkeletonLoader,
const props = defineProps({
url: {
type: String,
default: '',
},
props: {
url: {
type: String,
default: '',
},
isRtl: {
type: Boolean,
default: false,
},
isRtl: {
type: Boolean,
default: false,
},
data() {
return {
isLoading: true,
showEmptyState: !this.url,
};
isDirApplied: {
type: Boolean,
default: false,
},
watch: {
isRtl: {
immediate: true,
handler(value) {
this.$nextTick(() => {
const iframeElement = this.$el.querySelector('iframe');
if (iframeElement) {
iframeElement.onload = () => {
try {
const iframeDocument =
iframeElement.contentDocument ||
(iframeElement.contentWindow &&
iframeElement.contentWindow.document);
});
if (iframeDocument) {
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
}
} catch (e) {
// error
}
};
}
});
},
},
},
methods: {
handleIframeLoad() {
// Once loaded, the loading state is hidden
this.isLoading = false;
},
handleIframeError() {
// Hide the loading state and show the empty state when an error occurs
this.isLoading = false;
this.showEmptyState = true;
},
},
const { t } = useI18n();
const iframe = useTemplateRef('iframe');
const isLoading = ref(true);
const showEmptyState = ref(!props.url);
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
const applyDirection = () => {
if (!iframe.value) return;
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
try {
const doc =
iframe.value.contentDocument || iframe.value.contentWindow?.document;
if (doc?.documentElement) {
doc.documentElement.dir = direction.value;
doc.documentElement.setAttribute('data-dir-applied', 'true');
}
} catch (e) {
// error
}
};
watch(() => props.isRtl, applyDirection);
const handleIframeLoad = () => {
isLoading.value = false;
applyDirection();
};
const handleIframeError = () => {
isLoading.value = false;
showEmptyState.value = true;
};
</script>
@@ -66,6 +58,7 @@ export default {
<div class="relative overflow-hidden pb-1/2 h-full">
<iframe
v-if="url"
ref="iframe"
:src="url"
class="absolute w-full h-full top-0 left-0"
@load="handleIframeLoad"
@@ -79,7 +72,7 @@ export default {
v-if="showEmptyState"
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
>
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
/**
* Determine the best-matching locale from the list of locales allowed by the portal.
*
* The matching happens in the following order:
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
* (e.g., `fr` ➜ `fr`).
* 2. Base language match – the base part of a compound locale (before the underscore)
* matches (e.g., `fr_CA` ➜ `fr`).
* 3. Variant match – when the base language is selected but a regional variant exists
* in the portal list (e.g., `fr` ➜ `fr_BE`).
*
* If none of these rules find a match, the function returns `null`,
* Don't show popular articles if locale doesn't match with allowed locales
*
* @export
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
* @param {string[]} allowedLocales Array of locales enabled for the portal.
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
*/
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Ensure inputs are valid
if (
!selectedLocale ||
!Array.isArray(allowedLocales) ||
!allowedLocales.length
) {
return null;
}
const [lang] = selectedLocale.split('_');
const priorityMatches = [
selectedLocale, // exact match
lang, // base language match
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
];
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};
@@ -0,0 +1,28 @@
import { getMatchingLocale } from 'shared/helpers/portalHelper';
describe('portalHelper - getMatchingLocale', () => {
it('returns exact match when present', () => {
const result = getMatchingLocale('fr', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns base language match when exact variant not present', () => {
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns variant match when base language not present', () => {
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
expect(result).toBe('fr_BE');
});
it('returns null when no match found', () => {
const result = getMatchingLocale('de', ['en', 'fr']);
expect(result).toBeNull();
});
it('returns null for invalid inputs', () => {
expect(getMatchingLocale('', [])).toBeNull();
expect(getMatchingLocale(null, null)).toBeNull();
});
});
@@ -63,6 +63,9 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
const {
allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
const { allowed_locales: allowedLocales } = portal.value.config;
return getMatchingLocale(selectedLocale.value, allowedLocales);
});
const fetchArticles = () => {
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -64,7 +57,8 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
!!popularArticles.value.length
!!popularArticles.value.length &&
!!locale.value
);
onMounted(() => fetchArticles());
</script>
@@ -23,6 +23,7 @@ export const actions = {
commit('setError', false);
try {
if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
@@ -1,24 +1,16 @@
<script>
import IframeLoader from 'shared/components/IframeLoader.vue';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default {
name: 'ArticleViewer',
components: {
IframeLoader,
},
computed: {
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
},
};
</script>
<template>
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
<IframeLoader :url="$route.query.link" />
</div>
</template>
@@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener
message = extract_message_and_account(event)[0]
conversation = message.conversation
waiting_since = event.data[:waiting_since]
return if waiting_since.blank?
# When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
+3 -1
View File
@@ -34,9 +34,11 @@ class Attachment < ApplicationRecord
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].freeze
has_one_attached :file
belongs_to :account
belongs_to :message
has_one_attached :file
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
+10
View File
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
private
def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
def handle_resolved_status_change
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
return unless saved_change_to_status? && status == 'resolved'
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
+13 -1
View File
@@ -55,9 +55,11 @@ class Integrations::App
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
shopify_enabled?(account)
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'notion'
notion_enabled?(account)
else
true
end
@@ -113,4 +115,14 @@ class Integrations::App
all.detect { |app| app.id == params[:id] }
end
end
private
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
def notion_enabled?(account)
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
end
end
+4
View File
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
def notion?
app_id == 'notion'
end
def disable
update(status: 'disabled')
end
+3 -2
View File
@@ -24,6 +24,7 @@
#
# Indexes
#
# idx_messages_account_content_created (account_id,content_type,created_at)
# index_messages_on_account_created_type (account_id,created_at,message_type)
# index_messages_on_account_id (account_id)
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
@@ -122,12 +123,12 @@ class Message < ApplicationRecord
belongs_to :conversation, touch: true
belongs_to :sender, polymorphic: true, optional: true
after_create_commit :execute_after_create_commit_callbacks
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy_async
has_many :notifications, as: :primary_actor, dependent: :destroy_async
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
def channel_token
@@ -5,7 +5,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
build_messages
build_messages(config)
else
'No messages in this conversation'
end
@@ -23,11 +23,16 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
private
def build_messages
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
@record.messages.chat.order(created_at: :asc).each do |message|
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
messages.each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
message_text << format_message(message)
end
message_text
@@ -35,6 +40,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
@@ -1,5 +1,5 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
@@ -156,9 +156,14 @@
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol>
<symbol id="icon-notion" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+8 -1
View File
@@ -168,4 +168,11 @@
enabled: true
- name: crm_integration
display_name: CRM Integration
enabled: false
enabled: false
- name: channel_voice
display_name: Voice Channel
enabled: false
chatwoot_internal: true
- name: notion_integration
display_name: Notion Integration
enabled: false
+19
View File
@@ -288,6 +288,25 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Notion ------ ##
- name: NOTION_CLIENT_ID
display_title: 'Notion Client ID'
value:
locked: false
description: 'Notion client ID'
- name: NOTION_CLIENT_SECRET
display_title: 'Notion Client Secret'
value:
locked: false
description: 'Notion client secret'
type: secret
- name: NOTION_VERSION
display_title: 'Notion Version'
value: '2022-06-28'
locked: false
description: 'Notion version'
## ------ End of Configs added for Notion ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
+6
View File
@@ -63,6 +63,12 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
notion:
id: notion
logo: notion.png
i18n_key: notion
hook_type: account
allow_multiple_hooks: false
slack:
id: slack
logo: slack.png
+4
View File
@@ -257,6 +257,10 @@ en:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
+10
View File
@@ -228,6 +228,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :notion do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -265,6 +269,11 @@ Rails.application.routes.draw do
get :linked_issues
end
end
resource :notion, controller: 'notion', only: [] do
collection do
delete :destroy
end
end
end
resources :working_hours, only: [:update]
@@ -493,6 +502,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'notion/callback', to: 'notion/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
@@ -0,0 +1,16 @@
class CreateChannelVoice < ActiveRecord::Migration[7.0]
def change
create_table :channel_voice do |t|
t.string :phone_number, null: false
t.string :provider, null: false, default: 'twilio'
t.jsonb :provider_config, null: false
t.integer :account_id, null: false
t.jsonb :additional_attributes, default: {}
t.timestamps
end
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
@@ -1,6 +0,0 @@
class AddDocumentTypeAndFileToCaptainDocuments < ActiveRecord::Migration[7.0]
def change
add_column :captain_documents, :document_type, :integer, default: 0, null: false
add_index :captain_documents, :document_type
end
end
@@ -0,0 +1,22 @@
class AddIndexToMessages < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
# This index is added as a temporary fix for performance issues in the CSAT
# responses controller where we query messages with account_id, content_type
# and created_at. The current implementation (account.message.input_csat.count)
# times out with millions of messages.
#
# TODO: Create a dedicated csat_survey table and add entries when surveys are
# sent, then query this table instead of the entire messages table for better
# performance.
return if index_exists?(
:messages,
[:account_id, :content_type, :created_at],
name: 'idx_messages_account_content_created'
)
add_index :messages, [:account_id, :content_type, :created_at],
name: 'idx_messages_account_content_created', algorithm: :concurrently
end
end
+14 -3
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_06_23_120000) do
ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -289,11 +289,9 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_23_120000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.integer "document_type", default: 0, null: false
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["document_type"], name: "index_captain_documents_on_document_type"
t.index ["status"], name: "index_captain_documents_on_status"
end
@@ -445,6 +443,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_23_120000) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -815,6 +825,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_23_120000) do
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
-4
View File
@@ -34,8 +34,6 @@ RUN apk update && apk add --no-cache \
git \
curl \
xz \
poppler-utils \
tesseract-ocr \
&& mkdir -p /var/app \
&& gem install bundler
@@ -130,8 +128,6 @@ RUN apk update && apk add --no-cache \
imagemagick \
git \
vips \
poppler-utils \
tesseract-ocr \
&& gem install bundler
COPY --from=node /usr/local/bin/node /usr/local/bin/
@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
def allowed_channel_types
super + ['voice']
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
end
end
@@ -91,6 +91,12 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
notion:
name: 'Notion'
description: 'Configuration for setting up Notion Integration'
enabled: true
icon: 'icon-notion'
config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
@@ -1,30 +0,0 @@
class Captain::Documents::PdfProcessingJob < ApplicationJob
queue_as :low
def perform(document)
return unless document.pdf_document?
Rails.logger.info "Starting PDF processing for document #{document.id}"
begin
service = Captain::Tools::PdfProcessingService.new(document)
extracted_content = service.process
Rails.logger.info "Successfully processed PDF document #{document.id}, extracted #{extracted_content.length} characters"
rescue Captain::Tools::PdfProcessingService::PdfProcessingError => e
Rails.logger.error "PDF processing failed for document #{document.id}: #{e.message}"
# Mark document as failed (you may want to add a 'failed' status to the enum)
document.update!(status: :in_progress) # Keep as in_progress for potential retry
# Optionally, you could add a retry mechanism or notification
raise e
rescue StandardError => e
Rails.logger.error "Unexpected error processing PDF document #{document.id}: #{e.message}"
document.update!(status: :in_progress)
raise e
end
end
end
+1 -35
View File
@@ -4,7 +4,6 @@
#
# id :bigint not null, primary key
# content :text
# document_type :integer default("url"), not null
# external_link :string not null
# name :string
# status :integer default("in_progress"), not null
@@ -18,7 +17,6 @@
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_document_type (document_type)
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
@@ -39,13 +37,7 @@ class Captain::Document < ApplicationRecord
available: 1
}
enum document_type: {
url: 0,
pdf: 1
}
before_create :ensure_within_plan_limit
before_create :detect_document_type
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
@@ -55,38 +47,12 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
def url_document?
document_type == 'url'
end
def pdf_document?
document_type == 'pdf'
end
private
def enqueue_crawl_job
return if status != 'in_progress'
if pdf_document?
Captain::Documents::PdfProcessingJob.perform_later(self)
else
Captain::Documents::CrawlJob.perform_later(self)
end
end
def detect_document_type
return unless external_link.present?
# Check if URL points to a PDF file
uri = URI.parse(external_link)
if uri.path.downcase.end_with?('.pdf') || external_link.downcase.include?('.pdf')
self.document_type = :pdf
else
self.document_type = :url
end
rescue URI::InvalidURIError
self.document_type = :url
Captain::Documents::CrawlJob.perform_later(self)
end
def enqueue_response_builder_job
+64
View File
@@ -0,0 +1,64 @@
# == Schema Information
#
# Table name: channel_voice
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# phone_number :string not null
# provider :string default("twilio"), not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_voice_on_account_id (account_id)
# index_channel_voice_on_phone_number (phone_number) UNIQUE
#
class Channel::Voice < ApplicationRecord
include Channelable
self.table_name = 'channel_voice'
validates :phone_number, presence: true, uniqueness: true
validates :provider, presence: true
validates :provider_config, presence: true
# Validate phone number format (E.164 format)
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
# Provider-specific configs stored in JSON
validate :validate_provider_config
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
def name
"Voice (#{phone_number})"
end
def messaging_window_enabled?
false
end
private
def validate_provider_config
return if provider_config.blank?
case provider
when 'twilio'
validate_twilio_config
end
end
def validate_twilio_config
config = provider_config.with_indifferent_access
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
end
@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :copilot_threads, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
end
end
@@ -30,7 +30,7 @@ class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseServ
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
return 'Conversation not found' if conversation.blank?
conversation.to_llm_text
conversation.to_llm_text(include_private_messages: true)
end
def active?
@@ -1,57 +0,0 @@
require 'mini_magick'
class Captain::Tools::PdfPageBatcherService
PAGES_PER_BATCH = 3
def initialize(temp_files_tracker)
@temp_files = temp_files_tracker
end
def batch_pages(image_files)
return image_files if image_files.length <= PAGES_PER_BATCH
batched_images = []
image_files.each_slice(PAGES_PER_BATCH) do |batch|
if batch.length == 1
# Single page, use as-is
batched_images << batch.first
else
# Multiple pages, combine them
combined_image = combine_images(batch)
batched_images << combined_image
end
end
batched_images
end
private
def combine_images(image_paths)
# Create a vertical montage of the images
output_path = create_temp_file_path('.png')
MiniMagick::Tool::Montage.new do |montage|
image_paths.each { |path| montage << path }
montage.tile('1x') # Arrange in single column (vertically)
montage.geometry('+0+10') # Add 10px spacing between images
montage.background('white')
montage << output_path
end
@temp_files << output_path
output_path
rescue StandardError => e
Rails.logger.error "Failed to combine images: #{e.message}"
# Fallback to first image if combining fails
image_paths.first
end
def create_temp_file_path(extension)
temp_file = Tempfile.new(['combined_pages', extension])
temp_file.close
@temp_files << temp_file
temp_file.path
end
end
@@ -1,75 +0,0 @@
class Captain::Tools::PdfProcessingService
def initialize(document)
@document = document
@temp_files = []
end
def process
return unless @document.pdf_document?
content = extract_text_content
# Follow the same pattern as SimplePageCrawlParserJob
@document.update!(
content: content[0..14_999], # Same limit as crawler
status: :available
)
content
rescue StandardError => e
Rails.logger.error "PDF processing failed for document #{@document.id}: #{e.message}"
@document.update!(content: "Error processing PDF: #{e.message}", status: :available)
raise e
ensure
cleanup_temp_files
end
private
def extract_text_content
require 'down'
pdf_file = Down.download(@document.external_link)
@temp_files << pdf_file
output_dir = Dir.mktmpdir
@temp_files << output_dir
# Convert PDF to images
system("pdftoimage", "-png", "-r", "150", pdf_file.path, "#{output_dir}/page")
image_files = Dir.glob("#{output_dir}/page-*.png").sort
return fallback_content if image_files.empty?
# Batch pages for efficient processing
batcher = Captain::Tools::PdfPageBatcherService.new(@temp_files)
batched_images = batcher.batch_pages(image_files)
# Extract text using Google Vision
vision_extractor = Captain::Tools::VisionTextExtractorService.new
extracted_text = vision_extractor.extract_text_from_multiple_images(batched_images)
extracted_text.present? ? extracted_text : fallback_content
end
def fallback_content
"PDF Document: #{@document.name || 'Untitled'}\nSource: #{@document.external_link}"
end
def cleanup_temp_files
@temp_files.each do |file_or_dir|
case file_or_dir
when String # directory path
FileUtils.rm_rf(file_or_dir)
when Tempfile
file_or_dir.close
file_or_dir.unlink
else # Down::ChunkedIO or similar
file_or_dir.close if file_or_dir.respond_to?(:close)
file_or_dir.unlink if file_or_dir.respond_to?(:unlink)
end
rescue StandardError => e
Rails.logger.warn "Failed to cleanup temp file: #{e.message}"
end
end
end
@@ -1,68 +0,0 @@
class Captain::Tools::VisionTextExtractorService
class TextExtractionError < StandardError; end
def initialize
@use_vision = vision_available?
@vision = Google::Cloud::Vision.image_annotator if @use_vision
end
def extract_text_from_image(image_path)
if @use_vision
extract_with_vision(image_path)
else
extract_with_tesseract(image_path)
end
end
def extract_text_from_multiple_images(image_paths)
Rails.logger.info "Using #{@use_vision ? 'Google Vision' : 'Tesseract'} for text extraction"
extracted_texts = []
image_paths.each_with_index do |image_path, index|
Rails.logger.info "Extracting text from image #{index + 1}/#{image_paths.length}"
text = extract_text_from_image(image_path)
extracted_texts << text if text.present?
end
extracted_texts.join("\n\n")
end
private
def vision_available?
require 'google/cloud/vision'
Google::Cloud::Vision.image_annotator
true
rescue StandardError => e
Rails.logger.info "Google Vision not available, falling back to Tesseract: #{e.message}"
false
end
def extract_with_vision(image_path)
image = Google::Cloud::Vision::Image.new(image_path)
response = @vision.text_detection(image: image)
if response.error
raise TextExtractionError, "Vision API error: #{response.error.message}"
end
text_annotation = response.full_text_annotation
return "" unless text_annotation
text_annotation.text
rescue Google::Cloud::Error => e
Rails.logger.error "Google Vision API error: #{e.message}"
raise TextExtractionError, "Failed to extract text: #{e.message}"
end
def extract_with_tesseract(image_path)
require 'rtesseract'
RTesseract.new(image_path).to_s.strip
rescue StandardError => e
Rails.logger.error "Tesseract extraction error: #{e.message}"
raise TextExtractionError, "Failed to extract text with Tesseract: #{e.message}"
end
end
@@ -4,6 +4,8 @@ class Enterprise::Billing::CreateStripeCustomerService
DEFAULT_QUANTITY = 2
def perform
return if existing_subscription?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
@@ -50,4 +52,18 @@ class Enterprise::Billing::CreateStripeCustomerService
price_ids = default_plan['price_ids']
price_ids.first
end
def existing_subscription?
stripe_customer_id = account.custom_attributes['stripe_customer_id']
return false if stripe_customer_id.blank?
subscriptions = Stripe::Subscription.list(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
subscriptions.data.present?
end
end
@@ -4,7 +4,6 @@ json.assistant do
end
json.content resource.content
json.created_at resource.created_at.to_i
json.document_type resource.document_type
json.external_link resource.external_link
json.id resource.id
json.name resource.name
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,53 @@
require 'rails_helper'
RSpec.describe 'Notion Authorization API', type: :request do
let(:account) { create(:account) }
describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/notion/authorization"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unauthorized for agent' do
post "/api/v1/accounts/#{account.id}/notion/authorization",
headers: agent.create_new_auth_token,
params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a new authorization and returns the redirect url' do
post "/api/v1/accounts/#{account.id}/notion/authorization",
headers: administrator.create_new_auth_token,
params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:success)
# Validate URL components
url = response.parsed_body['url']
uri = URI.parse(url)
params = CGI.parse(uri.query)
expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
expect(params['response_type']).to eq(['code'])
expect(params['owner']).to eq(['user'])
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
expect(decoded_account).to eq(account)
end
end
end
end
@@ -0,0 +1,56 @@
require 'rails_helper'
RSpec.describe NotionConcern, type: :concern do
let(:controller_class) do
Class.new do
include NotionConcern
end
end
let(:controller) { controller_class.new }
describe '#notion_client' do
let(:client_id) { 'test_notion_client_id' }
let(:client_secret) { 'test_notion_client_secret' }
before do
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
end
it 'creates OAuth2 client with correct configuration' do
expect(OAuth2::Client).to receive(:new).with(
client_id,
client_secret,
{
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
}
)
controller.notion_client
end
it 'loads client credentials from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
controller.notion_client
end
it 'returns OAuth2::Client instance' do
client = controller.notion_client
expect(client).to be_an_instance_of(OAuth2::Client)
end
it 'configures client with Notion-specific endpoints' do
client = controller.notion_client
expect(client.site).to eq('https://api.notion.com')
expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
expect(client.options[:auth_scheme]).to eq(:basic_auth)
end
end
end
@@ -0,0 +1,112 @@
require 'rails_helper'
RSpec.describe Notion::CallbacksController, type: :request do
let(:account) { create(:account) }
let(:state) { account.to_sgid.to_s }
let(:oauth_code) { 'test_oauth_code' }
let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" }
let(:notion_response_body) do
{
'access_token' => 'notion_access_token_123',
'token_type' => 'bearer',
'workspace_name' => 'Test Workspace',
'workspace_id' => 'workspace_123',
'workspace_icon' => 'https://notion.so/icon.png',
'bot_id' => 'bot_123',
'owner' => {
'type' => 'user',
'user' => {
'id' => 'user_123',
'name' => 'Test User'
}
}
}
end
describe 'GET /notion/callback' do
before do
account.enable_features('notion_integration')
stub_const('ENV', ENV.to_hash.merge(
'FRONTEND_URL' => 'http://localhost:3000',
'NOTION_CLIENT_ID' => 'test_client_id',
'NOTION_CLIENT_SECRET' => 'test_client_secret'
))
controller = described_class.new
allow(controller).to receive(:account).and_return(account)
allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri)
allow(described_class).to receive(:new).and_return(controller)
end
context 'when OAuth callback is successful' do
before do
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
.to_return(
status: 200,
body: notion_response_body.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates a new integration hook' do
expect do
get '/notion/callback', params: { code: oauth_code, state: state }
end.to change(Integrations::Hook, :count).by(1)
hook = Integrations::Hook.last
expect(hook.access_token).to eq('notion_access_token_123')
expect(hook.app_id).to eq('notion')
expect(hook.status).to eq('enabled')
end
it 'sets correct hook attributes' do
get '/notion/callback', params: { code: oauth_code, state: state }
hook = Integrations::Hook.last
expect(hook.account).to eq(account)
expect(hook.app_id).to eq('notion')
expect(hook.access_token).to eq('notion_access_token_123')
expect(hook.status).to eq('enabled')
end
it 'stores notion workspace data in settings' do
get '/notion/callback', params: { code: oauth_code, state: state }
hook = Integrations::Hook.last
expect(hook.settings['token_type']).to eq('bearer')
expect(hook.settings['workspace_name']).to eq('Test Workspace')
expect(hook.settings['workspace_id']).to eq('workspace_123')
expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
expect(hook.settings['bot_id']).to eq('bot_123')
expect(hook.settings['owner']).to eq(notion_response_body['owner'])
end
it 'handles successful callback and creates hook' do
get '/notion/callback', params: { code: oauth_code, state: state }
# Due to controller mocking limitations in test,
# the redirect URL construction fails but hook creation succeeds
expect(Integrations::Hook.last.app_id).to eq('notion')
expect(response).to be_redirect
end
end
context 'when OAuth token request fails' do
before do
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
.to_return(
status: 400,
body: { error: 'invalid_grant' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'redirects to home page on error' do
get '/notion/callback', params: { code: oauth_code, state: state }
expect(response).to redirect_to('/')
end
end
end
end
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(2)
# Only count articles in the current locale (category.locale is 'en')
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
end
it 'returns articles count from all locales when locale parameter is not present' do
get "/hc/#{portal.slug}/articles.json"
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
end
@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
end
it 'creates a voice inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'Voice Inbox',
channel: { type: 'voice', phone_number: '+15551234567',
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16) } } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('Voice Inbox')
expect(response.body).to include('+15551234567')
end
end
end
@@ -1,69 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'document_type detection' do
it 'detects PDF documents from URL' do
document = assistant.documents.build(
name: 'Test PDF',
external_link: 'https://example.com/document.pdf'
)
document.save!
expect(document.document_type).to eq('pdf')
expect(document.pdf_document?).to be true
expect(document.url_document?).to be false
end
it 'detects URL documents for non-PDF links' do
document = assistant.documents.build(
name: 'Test Page',
external_link: 'https://example.com/page.html'
)
document.save!
expect(document.document_type).to eq('url')
expect(document.url_document?).to be true
expect(document.pdf_document?).to be false
end
it 'defaults to URL for invalid URIs' do
document = assistant.documents.build(
name: 'Invalid URL',
external_link: 'not-a-valid-url'
)
document.save!
expect(document.document_type).to eq('url')
end
end
describe 'job enqueueing' do
it 'enqueues PDF processing job for PDF documents' do
document = assistant.documents.build(
name: 'Test PDF',
external_link: 'https://example.com/document.pdf'
)
expect(Captain::Documents::PdfProcessingJob).to receive(:perform_later).with(document)
document.save!
end
it 'enqueues crawl job for URL documents' do
document = assistant.documents.build(
name: 'Test Page',
external_link: 'https://example.com/page.html'
)
expect(Captain::Documents::CrawlJob).to receive(:perform_later).with(document)
document.save!
end
end
end
@@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Voice do
let(:channel) { create(:channel_voice) }
it 'has a valid factory' do
expect(channel).to be_valid
end
describe 'validations' do
it 'validates presence of provider_config' do
channel.provider_config = nil
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include("can't be blank")
end
it 'validates presence of account_sid in provider_config' do
channel.provider_config = { auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
end
it 'validates presence of auth_token in provider_config' do
channel.provider_config = { account_sid: 'sid' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
end
it 'validates presence of api_key_sid in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
end
it 'validates presence of api_key_secret in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
end
it 'is valid with all required provider_config fields' do
channel.provider_config = {
account_sid: 'test_sid',
auth_token: 'test_token',
api_key_sid: 'test_key',
api_key_secret: 'test_secret'
}
expect(channel).to be_valid
end
end
describe '#name' do
it 'returns Voice with phone number' do
expect(channel.name).to include('Voice')
expect(channel.name).to include(channel.phone_number)
end
end
end
@@ -128,6 +128,29 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
expect(result).to eq(conversation.to_llm_text)
end
it 'includes private messages in the llm text format' do
# Create a regular message
create(:message,
conversation: conversation,
message_type: 'outgoing',
content: 'Regular message',
private: false)
# Create a private message
create(:message,
conversation: conversation,
message_type: 'outgoing',
content: 'Private note content',
private: true)
result = service.execute({ 'conversation_id' => conversation.display_id })
# Verify that the result includes both regular and private messages
expect(result).to include('Regular message')
expect(result).to include('Private note content')
expect(result).to include('[Private Note]')
end
context 'when conversation belongs to different account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
@@ -1,76 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Tools::PdfProcessingService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:document) do
assistant.documents.create!(
name: 'Test PDF',
external_link: 'https://example.com/test.pdf',
document_type: :pdf
)
end
subject { described_class.new(document) }
describe '#process' do
context 'when document is not a PDF' do
before { allow(document).to receive(:pdf_document?).and_return(false) }
it 'returns early without processing' do
expect(subject.process).to be_nil
end
end
context 'when document is a PDF' do
let(:mock_file) { double('file', path: '/tmp/test.pdf', close: nil, unlink: nil) }
let(:mock_batcher) { double('batcher') }
let(:mock_vision) { double('vision') }
before do
allow(Down).to receive(:download).and_return(mock_file)
allow(Dir).to receive(:mktmpdir).and_return('/tmp/pdf_images')
allow(Dir).to receive(:glob).and_return(['/tmp/image1.png', '/tmp/image2.png'])
allow(FileUtils).to receive(:rm_rf)
allow(Captain::Tools::PdfPageBatcherService).to receive(:new).and_return(mock_batcher)
allow(mock_batcher).to receive(:batch_pages).and_return(['/tmp/batched1.png'])
allow(Captain::Tools::VisionTextExtractorService).to receive(:new).and_return(mock_vision)
allow(mock_vision).to receive(:extract_text_from_multiple_images).and_return('Extracted text content')
end
it 'processes the PDF and updates document' do
expect { subject.process }.to change { document.reload.status }.to('available')
end
it 'sets extracted content on the document' do
subject.process
expect(document.reload.content).to eq('Extracted text content')
end
it 'uses batching service for page processing' do
expect(mock_batcher).to receive(:batch_pages).with(['/tmp/image1.png', '/tmp/image2.png'])
subject.process
end
it 'uses Vision API for text extraction' do
expect(mock_vision).to receive(:extract_text_from_multiple_images).with(['/tmp/batched1.png'])
subject.process
end
end
context 'when processing fails' do
before do
allow(Down).to receive(:download).and_raise(StandardError.new('Download failed'))
end
it 'logs error and updates document with error message' do
expect(Rails.logger).to receive(:error).with(/PDF processing failed/)
expect { subject.process }.to raise_error(StandardError)
expect(document.reload.content).to include('Error processing PDF')
end
end
end
end
@@ -1,98 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Tools::VisionTextExtractorService do
let(:image_path) { '/tmp/test_image.png' }
describe 'when Google Vision is available' do
subject { described_class.new }
let(:mock_vision) { double('vision_client') }
let(:mock_response) { double('response', error: nil) }
let(:mock_text_annotation) { double('text_annotation', text: 'Vision extracted text') }
before do
allow(Google::Cloud::Vision).to receive(:image_annotator).and_return(mock_vision)
allow(Google::Cloud::Vision::Image).to receive(:new).with(image_path).and_return(double('image'))
allow(mock_vision).to receive(:text_detection).and_return(mock_response)
allow(mock_response).to receive(:full_text_annotation).and_return(mock_text_annotation)
end
describe '#extract_text_from_image' do
it 'uses Vision API for text extraction' do
result = subject.extract_text_from_image(image_path)
expect(result).to eq('Vision extracted text')
end
context 'when Vision API returns error' do
before do
allow(mock_response).to receive(:error).and_return(double('error', message: 'API Error'))
end
it 'raises TextExtractionError' do
expect { subject.extract_text_from_image(image_path) }.to raise_error(
Captain::Tools::VisionTextExtractorService::TextExtractionError,
/Vision API error/
)
end
end
end
end
describe 'when Google Vision is not available' do
subject { described_class.new }
let(:mock_tesseract) { double('tesseract', to_s: 'Tesseract extracted text') }
before do
allow(Google::Cloud::Vision).to receive(:image_annotator).and_raise(StandardError.new('No credentials'))
allow(RTesseract).to receive(:new).with(image_path).and_return(mock_tesseract)
end
describe '#extract_text_from_image' do
it 'falls back to Tesseract for text extraction' do
result = subject.extract_text_from_image(image_path)
expect(result).to eq('Tesseract extracted text')
end
context 'when Tesseract fails' do
before do
allow(RTesseract).to receive(:new).and_raise(StandardError.new('Tesseract error'))
end
it 'raises TextExtractionError' do
expect { subject.extract_text_from_image(image_path) }.to raise_error(
Captain::Tools::VisionTextExtractorService::TextExtractionError,
/Failed to extract text with Tesseract/
)
end
end
end
end
describe '#extract_text_from_multiple_images' do
let(:image_paths) { ['/tmp/image1.png', '/tmp/image2.png'] }
before do
allow(Google::Cloud::Vision).to receive(:image_annotator).and_return(double('vision'))
allow(subject).to receive(:extract_text_from_image).with('/tmp/image1.png').and_return('Text from page 1')
allow(subject).to receive(:extract_text_from_image).with('/tmp/image2.png').and_return('Text from page 2')
end
it 'extracts text from multiple images and joins them' do
result = subject.extract_text_from_multiple_images(image_paths)
expect(result).to eq("Text from page 1\n\nText from page 2")
end
it 'skips empty results' do
allow(subject).to receive(:extract_text_from_image).with('/tmp/image2.png').and_return('')
result = subject.extract_text_from_multiple_images(image_paths)
expect(result).to eq('Text from page 1')
end
it 'logs which extraction method is being used' do
expect(Rails.logger).to receive(:info).with(/Using (Google Vision|Tesseract) for text extraction/)
subject.extract_text_from_multiple_images(image_paths)
end
end
end
@@ -6,6 +6,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
describe '#perform' do
before do
@@ -19,8 +20,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
@@ -78,4 +80,63 @@ describe Enterprise::Billing::CreateStripeCustomerService do
)
end
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create)
expect(Stripe::Subscription).to have_received(:create)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
end
context 'when customer has active subscriptions' do
before do
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
end
end
end
end
end
+20
View File
@@ -0,0 +1,20 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_voice, class: 'Channel::Voice' do
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
provider_config do
{
account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16)
}
end
account
after(:create) do |channel_voice|
create(:inbox, channel: channel_voice, account: channel_voice.account)
end
end
end
@@ -66,6 +66,34 @@ describe ReportingEventListener do
end
describe '#reply_created' do
let(:contact) { create(:contact, account: account) }
def create_customer_message(conversation, created_at: Time.current)
create(:message,
message_type: 'incoming',
account: account,
inbox: inbox,
conversation: conversation,
sender: contact,
created_at: created_at)
end
def create_agent_message(conversation, created_at: Time.current, sender: user)
create(:message,
message_type: 'outgoing',
account: account,
inbox: inbox,
conversation: conversation,
sender: sender,
created_at: created_at)
end
def create_reply_event(agent_message, waiting_since, event_time = nil)
Events::Base.new('reply.created', event_time || agent_message.created_at,
waiting_since: waiting_since,
message: agent_message)
end
it 'creates reply created event' do
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
listener.reply_created(event)
@@ -74,6 +102,88 @@ describe ReportingEventListener do
expect(events.length).to be 1
expect(events.first.value).to be_within(1).of(7200)
end
context 'when conversation is reopened' do
let(:resolved_conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user,
status: 'resolved', contact: contact)
end
context 'when customer sends message after resolution' do
it 'calculates reply time from the reopening message' do
customer_message_time = 3.hours.ago
create_customer_message(resolved_conversation, created_at: customer_message_time)
resolved_conversation.reload
expect(resolved_conversation.status).to eq('open')
agent_reply_time = 1.hour.ago
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
event = create_reply_event(agent_message, customer_message_time)
listener.reply_created(event)
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
expect(events.length).to be 1
expect(events.first.value).to be_within(60).of(7200)
end
end
context 'when conversation has multiple reopenings' do
it 'tracks reply time correctly for each reopening' do
create_customer_message(resolved_conversation, created_at: 5.hours.ago)
first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago)
event = create_reply_event(first_agent_reply, 5.hours.ago)
listener.reply_created(event)
resolved_conversation.update!(status: 'resolved')
create_customer_message(resolved_conversation, created_at: 2.hours.ago)
second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago)
event = create_reply_event(second_agent_reply, 2.hours.ago)
listener.reply_created(event)
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
.order(created_at: :asc)
expect(events.length).to be 2
expect(events.first.value).to be_within(60).of(3600)
expect(events.second.value).to be_within(60).of(1800)
end
end
context 'when conversation is manually reopened' do
it 'sets waiting_since when first customer message arrives after manual reopening' do
resolved_conversation.update!(status: 'open')
customer_message_time = 1.hour.ago
create_customer_message(resolved_conversation, created_at: customer_message_time)
agent_reply_time = 15.minutes.ago
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
event = create_reply_event(agent_message, customer_message_time)
listener.reply_created(event)
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
expect(events.length).to be 1
expect(events.first.value).to be_within(60).of(2700)
end
end
context 'when waiting_since is nil' do
it 'does not creates reply time events' do
agent_message = create_agent_message(resolved_conversation)
event = create_reply_event(agent_message, nil)
listener.reply_created(event)
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
expect(events.length).to be 0
end
end
end
end
describe '#first_reply_created' do
@@ -154,6 +154,27 @@ RSpec.describe ConversationReplyMailer do
expect(mail.message_id).to eq message.source_id
end
context 'when message is a CSAT survey' do
let(:csat_message) do
create(:message, conversation: conversation, account: account, message_type: 'template',
content_type: 'input_csat', content: 'How would you rate our support?', sender: agent)
end
it 'includes CSAT survey URL in outgoing_content' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
mail = described_class.email_reply(csat_message).deliver_now
expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
end
end
it 'uses outgoing_content for CSAT message body' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
mail = described_class.email_reply(csat_message).deliver_now
expect(mail.decoded).to include csat_message.outgoing_content
end
end
end
context 'with email attachments' do
it 'includes small attachments as email attachments' do
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
+113
View File
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
expect(message_window_service).to have_received(:can_reply?)
end
end
describe 'reply time calculation flows' do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
let(:conversation_start_time) { 5.hours.ago }
before do
create(:inbox_member, user: agent, inbox: inbox)
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:waiting_since, nil)
conversation.update_column(:created_at, conversation_start_time)
# rubocop:enable Rails/SkipsModelValidations
conversation.messages.destroy_all
conversation.reporting_events.destroy_all
conversation.reload
end
def create_customer_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'incoming',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: conversation.contact,
created_at: created_at)
end
message
end
def create_agent_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: conversation.assignee,
created_at: created_at)
end
message
end
it 'correctly tracks waiting_since and creates first response time events' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
# Agent replies - this should create first response event
agent_reply1_time = 4.hours.ago
create_agent_message(conversation, created_at: agent_reply1_time)
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
expect(first_response_events.count).to eq(1)
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
# the first response should also clear the waiting_since
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'does not reset waiting_since if customer sends another message' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
end
it 'records the correct reply_time for subsequent messages' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
create_agent_message(conversation, created_at: 2.hours.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1)
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'records zero reply time if an agent sends a message after resolution' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.toggle_status
expect(conversation.status).to eq('resolved')
conversation.toggle_status
expect(conversation.status).to eq('open')
conversation.reload
expect(conversation.waiting_since).to be_nil
create_agent_message(conversation, created_at: 1.hour.ago)
# update_waiting_since will ensure that no events were created since the waiting_since was nil
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
end
end
@@ -34,20 +34,21 @@ RSpec.describe MessageContentPresenter do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
allow(I18n).to receive(:t).with('conversations.survey.response', link: expected_url)
.and_return("Please rate this conversation, #{expected_url}")
expect(presenter.outgoing_content).to eq("Please rate this conversation, #{expected_url}")
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.outgoing_content).to include(expected_url)
end
end
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
end
end
end
end