feat(ee): Setup @chatwoot/captain NPM library (#10389)

--- 
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-11-12 07:09:09 +05:30
committed by GitHub
parent 7a45144526
commit 97d7b9d754
12 changed files with 710 additions and 262 deletions

View File

@@ -1,22 +1,47 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
before_action :hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
if params[:route] == '/sessions/profile'
'api/sessions/profile'
else
"api/accounts/#{hook.settings['account_id']}/#{params[:route]}"
end
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def permitted_params
params.permit(:method, :route, body: {})
end
end

View File

@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
}

View File

@@ -164,9 +164,25 @@ const menuItems = computed(() => {
},
{
name: 'Captain',
icon: 'i-lucide-bot',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
to: accountScopedRoute('captain'),
children: [
{
name: 'Documents',
label: 'Documents',
to: accountScopedRoute('captain', { page: 'documents' }),
},
{
name: 'Responses',
label: 'Responses',
to: accountScopedRoute('captain', { page: 'responses' }),
},
{
name: 'Playground',
label: 'Playground',
to: accountScopedRoute('captain', { page: 'playground' }),
},
],
},
{
name: 'Contacts',

View File

@@ -122,7 +122,9 @@ const toggleTrigger = () => {
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
v-bind="child"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
:active-child="activeChild"
/>

View File

@@ -22,7 +22,7 @@ const primaryMenuItems = accountId => [
key: 'captain',
label: 'CAPTAIN',
featureFlag: FEATURE_FLAGS.CAPTAIN,
toState: frontendURL(`accounts/${accountId}/captain`),
toState: frontendURL(`accounts/${accountId}/captain/documents`),
toStateName: 'captain',
},
{

View File

@@ -1,75 +1,107 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { nextTick, watch, computed } from 'vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { useStoreGetters } from 'dashboard/composables/store';
import { makeRouter, setupApp } from '@chatwoot/captain';
import integrations from '../../api/integrations';
import Spinner from 'shared/components/Spinner.vue';
const isLoading = ref(true);
const captainURL = ref('');
const hasError = ref(false);
const loadCaptainFrame = async integration => {
if (!integration || !integration.enabled) {
return;
}
try {
isLoading.value = true;
const { data } = await integrations.fetchCaptainURL();
captainURL.value = data.sso_url;
} catch (error) {
hasError.value = true;
} finally {
isLoading.value = false;
}
};
const props = defineProps({
page: {
type: String,
required: true,
},
});
const getters = useStoreGetters();
const routeMap = {
documents: '/app/accounts/[account_id]/documents/',
playground: '/app/accounts/[account_id]/playground/',
responses: '/app/accounts/[account_id]/responses/',
};
const resolvedRoute = computed(() => routeMap[props.page]);
let router = null;
watch(
() => props.page,
() => {
if (router) {
router.push({ name: resolvedRoute.value });
}
},
{ immediate: true }
);
const buildApp = () => {
router = makeRouter();
setupApp('#captain', {
router,
fetchFn: async (source, options) => {
const parsedSource = new URL(source);
let path = parsedSource.pathname;
if (path === `/api/sessions/profile`) {
path = '/sessions/profile';
} else {
path = path.replace(/^\/api\/accounts\/\d+/, '');
}
// include search params
path = `${path}${parsedSource.search}`;
const response = await IntegrationsAPI.requestCaptain({
method: options.method ?? 'GET',
route: path,
body: options.body ? JSON.parse(options.body) : null,
});
return {
json: () => {
return response.data;
},
ok: response.status >= 200 && response.status < 300,
status: response.status,
headers: response.headers,
};
},
});
router.push({ name: resolvedRoute.value });
};
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
onMounted(() => loadCaptainFrame(captainIntegration.value));
watch(captainIntegration, updatedIntegration =>
loadCaptainFrame(updatedIntegration)
watch(
() => captainIntegration.value,
(newValue, prevValue) => {
if (!prevValue && newValue) {
nextTick(() => buildApp());
}
},
{ immediate: true }
);
</script>
<template>
<div
class="flex-1 overflow-auto flex gap-8 flex-col font-inter text-slate-900 dark:text-slate-500"
>
<div class="flex-1 flex items-center justify-center">
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div
v-else-if="isLoading"
class="flex-1 flex items-center justify-center"
>
<Spinner color-scheme="primary" />
<span>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.LOADING_CONSOLE') }}</span>
</div>
<div v-else-if="!isLoading && hasError">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.FAILED_TO_LOAD_CONSOLE') }}
</div>
<iframe
v-else-if="!isLoading && captainURL"
:src="captainURL"
class="w-full min-h-[800px] h-full"
/>
</div>
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div v-else id="captain" class="w-full" />
</template>
<style>
@import '@chatwoot/captain/dist/style.css';
</style>

View File

@@ -21,13 +21,14 @@ export default {
component: AppContainer,
children: [
{
path: frontendURL('accounts/:accountId/captain'),
path: frontendURL('accounts/:accountId/captain/:page'),
name: 'captain',
component: Captain,
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
props: true,
},
...inboxRoutes,
...conversation.routes,

View File

@@ -219,7 +219,7 @@ Rails.application.routes.draw do
resources :apps, only: [:index, :show]
resource :captain, controller: 'captain', only: [] do
collection do
get :sso_url
post :proxy
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do

View File

@@ -31,6 +31,7 @@
],
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/captain": "0.0.3-alpha.4",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.25",
@@ -83,7 +84,7 @@
"video.js": "7.18.1",
"videojs-record": "4.5.0",
"videojs-wavesurfer": "3.8.0",
"vue": "^3.5.8",
"vue": "^3.5.12",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
@@ -101,10 +102,10 @@
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@iconify-json/ri": "^1.2.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.0",
"@iconify-json/lucide": "^1.2.10",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.11",
"@iconify-json/ri": "^1.2.3",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "2.0.1",
"@vue/test-utils": "^2.4.6",

618
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +1,79 @@
require 'rails_helper'
RSpec.describe 'Captain Integrations API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let!(:account) { create(:account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:hook) do
create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
assistant_id: '1',
account_id: '1'
})
end
let(:captain_api_url) { 'https://captain.example.com/' }
describe 'GET /api/v1/accounts/{account.id}/integrations/captain/sso_url' do
before do
InstallationConfig.where(name: 'CAPTAIN_API_URL').first_or_create(value: captain_api_url)
end
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/proxy' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
as: :json
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'return unauthorized if agent' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: agent.create_new_auth_token,
as: :json
context 'when valid request method and route' do
let(:route) { 'some_route' }
let(:method) { 'get' }
expect(response).to have_http_status(:unauthorized)
it 'proxies the request to Captain API' do
stub_request(:get, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
.with(headers: {
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json'
})
.to_return(status: 200, body: 'Success', headers: {})
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: method, route: route },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to eq('Success')
end
end
it 'returns 404 if hook is not available' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
context 'when HTTP method is invalid' do
it 'returns unprocessable entity' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'invalid', route: 'some_route', body: { some: 'data' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:internal_server_error)
end
end
it 'returns sso url if hook is available' do
InstallationConfig.where(name: 'CAPTAIN_APP_URL').first_or_create(value: 'https://app.chatwoot.com')
context 'when the hook is not found' do
before { hook.destroy }
hook = create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
account_id: '1',
assistant_id: '1',
inbox_ids: '1'
})
it 'returns not found' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
headers: agent.create_new_auth_token,
as: :json
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = response.parsed_body
params_string = "token=#{URI.encode_www_form_component(hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(hook['settings']['account_id'])}"
sso_url = "https://app.chatwoot.com/sso?#{params_string}"
expect(data['sso_url']).to eq(sso_url)
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -117,6 +117,13 @@ const tailwindConfig = {
width: 2,
height: 12,
},
captain: {
body: `<path d="M150.485 213.282C150.485 200.856 160.559 190.782 172.985 190.782C185.411 190.782 195.485 200.856 195.485 213.282V265.282C195.485 277.709 185.411 287.782 172.985 287.782C160.559 287.782 150.485 277.709 150.485 265.282V213.282Z" fill="currentColor"/>
<path d="M222.485 213.282C222.485 200.856 232.559 190.782 244.985 190.782C257.411 190.782 267.485 200.856 267.485 213.282V265.282C267.485 277.709 257.411 287.782 244.985 287.782C232.559 287.782 222.485 277.709 222.485 265.282V213.282Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M412.222 109.961C317.808 96.6217 240.845 96.0953 144.309 109.902C119.908 113.392 103.762 115.751 91.4521 119.354C80.0374 122.694 73.5457 126.678 68.1762 132.687C57.0576 145.13 55.592 159.204 54.0765 208.287C52.587 256.526 55.5372 299.759 61.1249 348.403C64.1025 374.324 66.1515 391.817 69.4229 405.117C72.526 417.732 76.2792 424.515 81.4954 429.708C86.7533 434.942 93.4917 438.633 105.859 441.629C118.94 444.797 136.104 446.713 161.613 449.5C244.114 458.514 305.869 458.469 388.677 449.548C414.495 446.767 431.939 444.849 445.216 441.702C457.83 438.712 464.612 435.047 469.797 429.962C474.873 424.985 478.752 418.118 482.116 404.874C485.626 391.056 488.014 372.772 491.47 345.913C497.636 297.99 502.076 255.903 502.248 209.798C502.433 160.503 501.426 146.477 490.181 133.468C484.75 127.185 478.148 123.053 466.473 119.612C453.865 115.897 437.283 113.502 412.222 109.961ZM138.414 68.5711C238.977 54.1882 319.888 54.7514 418.047 68.6199L419.483 68.8227C442.724 72.1054 462.359 74.8786 478.244 79.5601C495.387 84.6124 509.724 92.2821 521.706 106.145C544.308 132.295 544.161 163.321 543.965 204.542C543.956 206.327 543.948 208.131 543.941 209.954C543.758 258.703 539.048 302.844 532.821 351.247L532.656 352.528C529.407 377.787 526.729 398.602 522.522 415.166C518.098 432.584 511.485 447.517 498.968 459.792C486.56 471.959 471.897 478.282 454.819 482.33C438.691 486.153 418.624 488.314 394.436 490.919L393.136 491.059C307.385 500.297 242.618 500.349 157.091 491.004L155.772 490.86C131.921 488.255 112.062 486.086 96.056 482.209C79.0408 478.087 64.4759 471.637 52.1005 459.316C39.6835 446.955 33.1618 432.265 28.94 415.102C24.9582 398.915 22.6435 378.759 19.8561 354.488L19.7052 353.174C13.9746 303.287 10.8315 257.908 12.4035 206.997C12.4606 205.15 12.5151 203.323 12.5691 201.516C13.7911 160.603 14.7077 129.914 37.1055 104.847C48.989 91.5477 63.035 84.1731 79.7563 79.2794C95.2643 74.7408 114.386 72.0068 137.018 68.7707C137.482 68.7044 137.948 68.6379 138.414 68.5711Z" fill="currentColor"/>`,
width: 556,
height: 556,
},
},
},
...getIconCollections(['lucide', 'logos', 'ri']),