Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3854895a13 | ||
|
|
b9d022ab68 | ||
|
|
ed229eb1ae | ||
|
|
39e20f771a | ||
|
|
245e00d7a8 | ||
|
|
3af9e8714a | ||
|
|
df00820878 | ||
|
|
aafd99bed1 | ||
|
|
22b284b8e7 | ||
|
|
3fd054fcc8 | ||
|
|
848b833bc1 | ||
|
|
c60b47fd57 | ||
|
|
5bbda8e348 | ||
|
|
f07e171c0b | ||
|
|
36d09597a2 | ||
|
|
c3619f51a1 | ||
|
|
141285b036 | ||
|
|
f429db89b4 |
@@ -31,6 +31,14 @@ 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
|
||||
|
||||
+33
-17
@@ -172,8 +172,6 @@ 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)
|
||||
@@ -277,14 +275,16 @@ GEM
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.20.0)
|
||||
gapic-common (1.0.0)
|
||||
faraday (>= 1.9, < 3.a)
|
||||
faraday-retry (>= 1.0, < 3.a)
|
||||
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)
|
||||
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)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -329,6 +329,13 @@ 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)
|
||||
@@ -336,9 +343,10 @@ GEM
|
||||
grpc (~> 1.41)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleauth (1.11.2)
|
||||
googleauth (1.14.0)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
@@ -383,6 +391,7 @@ 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)
|
||||
@@ -435,12 +444,10 @@ GEM
|
||||
json (>= 1.8)
|
||||
rexml
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
launchy (2.5.2)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
letter_opener (1.8.1)
|
||||
launchy (>= 2.2, < 3)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
@@ -554,6 +561,9 @@ 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)
|
||||
@@ -567,7 +577,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (6.0.0)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -676,6 +686,7 @@ 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)
|
||||
@@ -751,6 +762,7 @@ 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
|
||||
@@ -931,6 +943,7 @@ 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
|
||||
@@ -954,6 +967,7 @@ DEPENDENCIES
|
||||
lograge (~> 0.14.0)
|
||||
maxminddb
|
||||
meta_request (>= 0.8.3)
|
||||
mini_magick
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
@@ -963,6 +977,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
pdftoimage
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -982,6 +997,7 @@ DEPENDENCIES
|
||||
reverse_markdown
|
||||
rspec-rails (>= 6.1.5)
|
||||
rspec_junit_formatter
|
||||
rtesseract
|
||||
rubocop
|
||||
rubocop-factory_bot
|
||||
rubocop-performance
|
||||
@@ -1024,4 +1040,4 @@ RUBY VERSION
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.16
|
||||
2.6.7
|
||||
|
||||
@@ -81,15 +81,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
return unless %w[web_widget api email line telegram whatsapp sms].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
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
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,11 +7,7 @@ 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,7 +39,6 @@ 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]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* 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-3 rtl:right-3">
|
||||
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex relative justify-between w-full gap-1">
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,12 +19,6 @@ 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,21 +1,51 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick } 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([
|
||||
@@ -26,10 +56,6 @@ 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);
|
||||
|
||||
@@ -85,7 +111,7 @@ onMounted(() => {
|
||||
<div class="relative flex flex-col min-w-0 gap-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="uniqueId"
|
||||
:for="id"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -93,7 +119,7 @@ onMounted(() => {
|
||||
<!-- Added prefix slot to allow adding icons to the input -->
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="uniqueId"
|
||||
:id="id"
|
||||
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" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||
<img :src="src" :alt="title" 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
|
||||
? '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'
|
||||
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -41,10 +41,6 @@ export default {
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return this.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
@@ -54,7 +50,6 @@ export default {
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,10 +125,6 @@ export const useInbox = () => {
|
||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
isAFacebookInbox,
|
||||
@@ -146,6 +142,5 @@ export const useInbox = () => {
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ export const INBOX_TYPES = {
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
@@ -23,7 +22,6 @@ 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';
|
||||
@@ -38,7 +36,6 @@ 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';
|
||||
@@ -50,7 +47,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -89,9 +85,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -131,9 +124,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.INSTAGRAM:
|
||||
return 'brand-instagram';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -268,46 +268,6 @@
|
||||
"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.",
|
||||
@@ -829,8 +789,7 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
"INSTAGRAM": "Instagram"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,14 +328,6 @@
|
||||
"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": {
|
||||
@@ -545,8 +537,6 @@
|
||||
"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,13 +157,6 @@ 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;
|
||||
};
|
||||
@@ -277,11 +270,7 @@ onMounted(() => {
|
||||
<template #controls>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
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,
|
||||
}"
|
||||
class="mb-4 -mt-3 flex justify-between items-center"
|
||||
>
|
||||
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
@@ -317,18 +306,13 @@ onMounted(() => {
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
|
||||
>
|
||||
<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>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
@@ -338,23 +322,17 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
slate
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-4
@@ -1,7 +1,6 @@
|
||||
<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: {
|
||||
@@ -12,8 +11,6 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['back', 'insert']);
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const onBack = e => {
|
||||
e.stopPropagation();
|
||||
emit('back');
|
||||
@@ -38,7 +35,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" :is-rtl="isRTL" is-dir-applied />
|
||||
<IframeLoader :url="url" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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,
|
||||
@@ -23,7 +22,6 @@ const channelViewList = {
|
||||
line: Line,
|
||||
telegram: Telegram,
|
||||
instagram: Instagram,
|
||||
voice: Voice,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -36,7 +36,6 @@ export default {
|
||||
{ key: 'telegram', name: 'Telegram' },
|
||||
{ key: 'line', name: 'Line' },
|
||||
{ key: 'instagram', name: 'Instagram' },
|
||||
{ key: 'voice', name: 'Voice' },
|
||||
];
|
||||
},
|
||||
...mapGetters({
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<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,7 +29,6 @@ const i18nMap = {
|
||||
'Channel::Line': 'LINE',
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Voice': 'VOICE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<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>
|
||||
-10
@@ -8,7 +8,6 @@ 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 {
|
||||
@@ -91,15 +90,6 @@ 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',
|
||||
|
||||
+4
-28
@@ -75,34 +75,10 @@ export default {
|
||||
onRegistrationSuccess() {
|
||||
this.hasEnabledPushPermissions = true;
|
||||
},
|
||||
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
|
||||
})
|
||||
);
|
||||
onRequestPermissions() {
|
||||
requestPushPermissions({
|
||||
onSuccess: this.onRegistrationSuccess,
|
||||
});
|
||||
},
|
||||
getPushSubscription() {
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
|
||||
+3
-5
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
|
||||
cellProps.getValue()
|
||||
);
|
||||
|
||||
const columns = computed(() => [
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
|
||||
width: 300,
|
||||
@@ -90,7 +90,7 @@ const columns = computed(() => [
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
]);
|
||||
];
|
||||
|
||||
const renderAvgTime = value => (value ? formatTime(value) : '--');
|
||||
|
||||
@@ -142,9 +142,7 @@ const table = useVueTable({
|
||||
get data() {
|
||||
return tableData.value;
|
||||
},
|
||||
get columns() {
|
||||
return columns.value;
|
||||
},
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,29 @@ import { throwErrorMessage } from '../utils/api';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
import { channelActions, buildInboxData } from './inboxes/channelActions';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const state = {
|
||||
records: [],
|
||||
@@ -198,12 +220,6 @@ 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 {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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,28 +62,6 @@ 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] });
|
||||
|
||||
@@ -112,14 +112,11 @@ export const InitializationHelpers = {
|
||||
},
|
||||
|
||||
setDirectionAttribute: () => {
|
||||
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 portalElement = document.getElementById('portal');
|
||||
if (!portalElement) return;
|
||||
|
||||
const localeFromHtml = htmlElement.lang;
|
||||
htmlElement.dir =
|
||||
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
|
||||
const locale = document.querySelector('.locale-switcher')?.value;
|
||||
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
||||
},
|
||||
|
||||
initializeThemesInPortal: initializeTheme,
|
||||
|
||||
@@ -1,56 +1,64 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
<script>
|
||||
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
export default {
|
||||
name: 'IframeLoader',
|
||||
components: {
|
||||
ArticleSkeletonLoader,
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
isDirApplied: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
showEmptyState: !this.url,
|
||||
};
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
||||
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;
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -58,7 +66,6 @@ const handleIframeError = () => {
|
||||
<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"
|
||||
@@ -72,7 +79,7 @@ const handleIframeError = () => {
|
||||
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>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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,9 +63,6 @@ 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,7 +7,6 @@ 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();
|
||||
@@ -21,8 +20,17 @@ const articleUiFlags = useMapGetter('article/uiFlags');
|
||||
|
||||
const locale = computed(() => {
|
||||
const { locale: selectedLocale } = i18n;
|
||||
const { allowed_locales: allowedLocales } = portal.value.config;
|
||||
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||
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 fetchArticles = () => {
|
||||
@@ -38,7 +46,6 @@ 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
|
||||
@@ -57,8 +64,7 @@ const hasArticles = computed(
|
||||
() =>
|
||||
!articleUiFlags.value.isFetching &&
|
||||
!articleUiFlags.value.isError &&
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
!!popularArticles.value.length
|
||||
);
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,6 @@ export const actions = {
|
||||
commit('setError', false);
|
||||
|
||||
try {
|
||||
if (!locale) return;
|
||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||
if (cachedData) {
|
||||
commit('setArticles', cachedData);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<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" />
|
||||
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,10 +47,6 @@ 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(
|
||||
|
||||
@@ -34,11 +34,9 @@ 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,
|
||||
|
||||
@@ -198,21 +198,11 @@ 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
|
||||
|
||||
@@ -55,11 +55,9 @@ class Integrations::App
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
when 'leadsquared'
|
||||
account.feature_enabled?('crm_integration')
|
||||
when 'notion'
|
||||
notion_enabled?(account)
|
||||
else
|
||||
true
|
||||
end
|
||||
@@ -115,14 +113,4 @@ 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
|
||||
|
||||
@@ -53,10 +53,6 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
|
||||
def disable
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
#
|
||||
# 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)
|
||||
@@ -123,12 +122,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(config)
|
||||
build_messages
|
||||
else
|
||||
'No messages in this conversation'
|
||||
end
|
||||
@@ -23,16 +23,11 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
|
||||
private
|
||||
|
||||
def build_messages(config = {})
|
||||
def build_messages
|
||||
return "No messages in this conversation\n" if @record.messages.empty?
|
||||
|
||||
message_text = ''
|
||||
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]
|
||||
|
||||
@record.messages.chat.order(created_at: :asc).each do |message|
|
||||
message_text << format_message(message)
|
||||
end
|
||||
message_text
|
||||
@@ -40,7 +35,6 @@ 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.outgoing_content).render_message %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
||||
<% end %>
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
|
||||
@@ -156,14 +156,9 @@
|
||||
<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: 42 KiB After Width: | Height: | Size: 41 KiB |
+1
-8
@@ -168,11 +168,4 @@
|
||||
enabled: true
|
||||
- name: crm_integration
|
||||
display_name: CRM Integration
|
||||
enabled: false
|
||||
- name: channel_voice
|
||||
display_name: Voice Channel
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
enabled: false
|
||||
@@ -288,25 +288,6 @@
|
||||
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'
|
||||
|
||||
@@ -63,12 +63,6 @@ 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
|
||||
|
||||
@@ -257,10 +257,6 @@ 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.'
|
||||
|
||||
@@ -228,10 +228,6 @@ 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]
|
||||
@@ -269,11 +265,6 @@ 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]
|
||||
|
||||
@@ -502,7 +493,6 @@ 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'
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
+3
-14
@@ -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_27_195529) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_06_23_120000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -289,9 +289,11 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) 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
|
||||
|
||||
@@ -443,18 +445,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) 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"
|
||||
@@ -825,7 +815,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) 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"
|
||||
|
||||
@@ -34,6 +34,8 @@ RUN apk update && apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
xz \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
&& mkdir -p /var/app \
|
||||
&& gem install bundler
|
||||
|
||||
@@ -128,6 +130,8 @@ 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,28 +6,4 @@ 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,12 +91,6 @@ 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'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# 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
|
||||
@@ -17,6 +18,7 @@
|
||||
# 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
|
||||
@@ -37,7 +39,13 @@ 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
|
||||
@@ -47,12 +55,38 @@ 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'
|
||||
|
||||
Captain::Documents::CrawlJob.perform_later(self)
|
||||
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
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# == 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,6 +11,5 @@ 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(include_private_messages: true)
|
||||
conversation.to_llm_text
|
||||
end
|
||||
|
||||
def active?
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
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
|
||||
@@ -0,0 +1,68 @@
|
||||
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,8 +4,6 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
DEFAULT_QUANTITY = 2
|
||||
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(
|
||||
{
|
||||
@@ -52,18 +50,4 @@ 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,6 +4,7 @@ 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.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,53 +0,0 @@
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
@@ -1,112 +0,0 @@
|
||||
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,14 +65,6 @@ 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,22 +22,6 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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,29 +128,6 @@ 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) }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
@@ -0,0 +1,98 @@
|
||||
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,7 +6,6 @@ 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
|
||||
@@ -20,9 +19,8 @@ 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(
|
||||
{
|
||||
@@ -80,63 +78,4 @@ 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
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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,34 +66,6 @@ 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)
|
||||
@@ -102,88 +74,6 @@ 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,27 +154,6 @@ 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',
|
||||
|
||||
@@ -836,117 +836,4 @@ 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,21 +34,20 @@ 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
|
||||
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
|
||||
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}")
|
||||
end
|
||||
|
||||
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user