Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4521d44a07 | ||
|
|
b7f3f72b9c | ||
|
|
b26862e3d8 |
@@ -0,0 +1,14 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include NotionConcern
|
||||
|
||||
def create
|
||||
redirect_url = notion_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/notion/callback",
|
||||
response_type: 'code',
|
||||
owner: 'user',
|
||||
state: state,
|
||||
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
|
||||
}
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
module NotionConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def notion_client
|
||||
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
|
||||
|
||||
::OAuth2::Client.new(app_id, app_secret, {
|
||||
site: 'https://api.notion.com',
|
||||
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
|
||||
token_url: 'https://api.notion.com/v1/oauth/token',
|
||||
auth_scheme: :basic_auth
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
''
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Notion::CallbacksController < OauthCallbackController
|
||||
include NotionConcern
|
||||
|
||||
private
|
||||
|
||||
def provider_name
|
||||
'notion'
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
notion_client
|
||||
end
|
||||
|
||||
def handle_response
|
||||
hook = account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'notion',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
workspace_name: parsed_body['workspace_name'],
|
||||
workspace_id: parsed_body['workspace_id'],
|
||||
workspace_icon: parsed_body['workspace_icon'],
|
||||
bot_id: parsed_body['bot_id'],
|
||||
owner: parsed_body['owner']
|
||||
}
|
||||
)
|
||||
|
||||
hook.save!
|
||||
redirect_to notion_redirect_uri
|
||||
end
|
||||
|
||||
def notion_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class NotionOAuthClient extends ApiClient {
|
||||
constructor() {
|
||||
super('notion', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization() {
|
||||
return axios.post(`${this.url}/authorization`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotionOAuthClient();
|
||||
@@ -328,6 +328,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import notionClient from 'dashboard/api/notion_auth.js';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'notion');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const authorize = async () => {
|
||||
const response = await notionClient.generateAuthorization();
|
||||
const {
|
||||
data: { url },
|
||||
} = response;
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const initializeNotionIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'notion');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeNotionIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'),
|
||||
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'),
|
||||
}"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonNext
|
||||
faded
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
|
||||
@click="authorize"
|
||||
/>
|
||||
</template>
|
||||
</Integration>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+10
@@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue';
|
||||
import Slack from './Slack.vue';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
|
||||
export default {
|
||||
@@ -90,6 +91,15 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'notion',
|
||||
name: 'settings_integrations_notion',
|
||||
component: Notion,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
@@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener
|
||||
message = extract_message_and_account(event)[0]
|
||||
conversation = message.conversation
|
||||
waiting_since = event.data[:waiting_since]
|
||||
|
||||
return if waiting_since.blank?
|
||||
|
||||
# When waiting_since is nil, set reply_time to 0
|
||||
reply_time = message.created_at.to_i - waiting_since.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
|
||||
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
|
||||
private
|
||||
|
||||
def execute_after_update_commit_callbacks
|
||||
handle_resolved_status_change
|
||||
notify_status_change
|
||||
create_activity
|
||||
notify_conversation_updation
|
||||
end
|
||||
|
||||
def handle_resolved_status_change
|
||||
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
|
||||
return unless saved_change_to_status? && status == 'resolved'
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_column(:waiting_since, nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def ensure_snooze_until_reset
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
@@ -55,9 +55,11 @@ class Integrations::App
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
account.feature_enabled?('crm_integration')
|
||||
when 'notion'
|
||||
notion_enabled?(account)
|
||||
else
|
||||
true
|
||||
end
|
||||
@@ -113,4 +115,14 @@ class Integrations::App
|
||||
all.detect { |app| app.id == params[:id] }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
def notion_enabled?(account)
|
||||
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
|
||||
def disable
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
@@ -156,9 +156,14 @@
|
||||
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-notion" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
@@ -173,3 +173,6 @@
|
||||
display_name: Voice Channel
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
|
||||
@@ -288,6 +288,25 @@
|
||||
type: secret
|
||||
## ------ End of Configs added for Linear ------ ##
|
||||
|
||||
## ------ Configs added for Notion ------ ##
|
||||
- name: NOTION_CLIENT_ID
|
||||
display_title: 'Notion Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Notion client ID'
|
||||
- name: NOTION_CLIENT_SECRET
|
||||
display_title: 'Notion Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Notion client secret'
|
||||
type: secret
|
||||
- name: NOTION_VERSION
|
||||
display_title: 'Notion Version'
|
||||
value: '2022-06-28'
|
||||
locked: false
|
||||
description: 'Notion version'
|
||||
## ------ End of Configs added for Notion ------ ##
|
||||
|
||||
## ------ Configs added for Slack ------ ##
|
||||
- name: SLACK_CLIENT_ID
|
||||
display_title: 'Slack Client ID'
|
||||
|
||||
@@ -63,6 +63,12 @@ linear:
|
||||
action: https://linear.app/oauth/authorize
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
notion:
|
||||
id: notion
|
||||
logo: notion.png
|
||||
i18n_key: notion
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
slack:
|
||||
id: slack
|
||||
logo: slack.png
|
||||
|
||||
@@ -257,6 +257,10 @@ en:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
short_description: 'Access order details and customer data from your Shopify store.'
|
||||
|
||||
@@ -228,6 +228,10 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :notion do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
@@ -265,6 +269,11 @@ Rails.application.routes.draw do
|
||||
get :linked_issues
|
||||
end
|
||||
end
|
||||
resource :notion, controller: 'notion', only: [] do
|
||||
collection do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -493,6 +502,7 @@ Rails.application.routes.draw do
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
get 'notion/callback', to: 'notion/callbacks#show'
|
||||
# ----------------------------------------------------------------------
|
||||
# Routes for external service verifications
|
||||
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
|
||||
|
||||
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
params[:message_content],
|
||||
message_history
|
||||
)
|
||||
|
||||
render json: response
|
||||
|
||||
@@ -91,6 +91,12 @@ linear:
|
||||
enabled: true
|
||||
icon: 'icon-linear'
|
||||
config_key: 'linear'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
description: 'Configuration for setting up Notion Integration'
|
||||
enabled: true
|
||||
icon: 'icon-notion'
|
||||
config_key: 'notion'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
description: 'Configuration for setting up Slack Integration'
|
||||
|
||||
@@ -26,7 +26,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
@conversation.messages.incoming.last.content,
|
||||
collect_previous_messages
|
||||
)
|
||||
|
||||
return process_action('handoff') if handoff_requested?
|
||||
@@ -42,11 +43,33 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
{
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
{
|
||||
content: message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
return message.content if message.content.present?
|
||||
return 'User has shared a message without content' unless message.attachments.any?
|
||||
|
||||
audio_transcriptions = extract_audio_transcriptions(message.attachments)
|
||||
return audio_transcriptions if audio_transcriptions.present?
|
||||
|
||||
'User has shared an attachment'
|
||||
end
|
||||
|
||||
def extract_audio_transcriptions(attachments)
|
||||
audio_attachments = attachments.where(file_type: :audio)
|
||||
return '' if audio_attachments.blank?
|
||||
|
||||
transcriptions = ''
|
||||
audio_attachments.each do |attachment|
|
||||
result = Messages::AudioTranscriptionService.new(attachment).perform
|
||||
transcriptions += result[:transcriptions] if result[:success]
|
||||
end
|
||||
transcriptions
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
@@ -55,10 +78,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
message.message_type == 'incoming' ? 'user' : 'system'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def handoff_requested?
|
||||
@response['response'] == 'conversation_handoff'
|
||||
end
|
||||
|
||||
@@ -12,16 +12,9 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
register_tools
|
||||
end
|
||||
|
||||
# additional_message: A single message (String) from the user that should be appended to the chat.
|
||||
# It can be an empty String or nil when you only want to supply historical messages.
|
||||
# message_history: An Array of already formatted messages that provide the previous context.
|
||||
# role: The role for the additional_message (defaults to `user`).
|
||||
#
|
||||
# NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
|
||||
# positional ordering.
|
||||
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
||||
@messages += message_history
|
||||
@messages << { role: role, content: additional_message } if additional_message.present?
|
||||
def generate_response(input, previous_messages = [], role = 'user')
|
||||
@messages += previous_messages
|
||||
@messages << { role: role, content: input } if input.present?
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
class Captain::OpenAiMessageBuilderService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def generate_content
|
||||
parts = []
|
||||
parts << text_part(@message.content) if @message.content.present?
|
||||
parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
|
||||
|
||||
return 'Message without content' if parts.blank?
|
||||
return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
|
||||
|
||||
parts
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def text_part(text)
|
||||
{ type: 'text', text: text }
|
||||
end
|
||||
|
||||
def image_part(image_url)
|
||||
{ type: 'image_url', image_url: { url: image_url } }
|
||||
end
|
||||
|
||||
def attachment_parts(attachments)
|
||||
image_attachments = attachments.where(file_type: :image)
|
||||
image_content = image_parts(image_attachments)
|
||||
|
||||
transcription = extract_audio_transcriptions(attachments)
|
||||
transcription_part = text_part(transcription) if transcription.present?
|
||||
|
||||
attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
|
||||
|
||||
[image_content, transcription_part, attachment_part].flatten.compact
|
||||
end
|
||||
|
||||
def image_parts(image_attachments)
|
||||
image_attachments.each_with_object([]) do |attachment, parts|
|
||||
url = get_attachment_url(attachment)
|
||||
parts << image_part(url) if url.present?
|
||||
end
|
||||
end
|
||||
|
||||
def get_attachment_url(attachment)
|
||||
return attachment.external_url if attachment.external_url.present?
|
||||
|
||||
attachment.file.attached? ? attachment.file_url : nil
|
||||
end
|
||||
|
||||
def extract_audio_transcriptions(attachments)
|
||||
audio_attachments = attachments.where(file_type: :audio)
|
||||
return '' if audio_attachments.blank?
|
||||
|
||||
audio_attachments.map do |attachment|
|
||||
result = Messages::AudioTranscriptionService.new(attachment).perform
|
||||
result[:success] ? result[:transcriptions] : ''
|
||||
end.join
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,6 @@ class Seeders::AccountSeeder
|
||||
seed_canned_responses
|
||||
seed_inboxes
|
||||
seed_contacts
|
||||
seed_csat_responses
|
||||
end
|
||||
|
||||
def set_up_account
|
||||
@@ -117,17 +116,43 @@ class Seeders::AccountSeeder
|
||||
contact.update!(contact_data.slice('name', 'email'))
|
||||
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
|
||||
end
|
||||
contact_data['conversations'].each do |conversation_data|
|
||||
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
|
||||
contact_inbox = inbox.contact_inboxes.create_or_find_by!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
|
||||
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Create conversations after all contacts are created
|
||||
Seeders::ConversationSeeder.new(account: @account, contact_data: @account_data['contacts']).perform!
|
||||
def create_conversation(contact_inbox:, conversation_data:)
|
||||
assignee = User.from_email(conversation_data['assignee']) if conversation_data['assignee'].present?
|
||||
conversation = contact_inbox.conversations.create!(account: contact_inbox.inbox.account, contact: contact_inbox.contact,
|
||||
inbox: contact_inbox.inbox, assignee: assignee)
|
||||
create_messages(conversation: conversation, messages: conversation_data['messages'])
|
||||
conversation.update_labels(conversation_data[:labels]) if conversation_data[:labels].present?
|
||||
conversation.update!(priority: conversation_data[:priority]) if conversation_data[:priority].present?
|
||||
end
|
||||
|
||||
def create_messages(conversation:, messages:)
|
||||
messages.each do |message_data|
|
||||
sender = find_message_sender(conversation, message_data)
|
||||
conversation.messages.create!(
|
||||
message_data.slice('content', 'message_type').merge(
|
||||
account: conversation.inbox.account, sender: sender, inbox: conversation.inbox
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def find_message_sender(conversation, message_data)
|
||||
if message_data['message_type'] == 'incoming'
|
||||
conversation.contact
|
||||
elsif message_data['sender'].present?
|
||||
User.from_email(message_data['sender'])
|
||||
end
|
||||
end
|
||||
|
||||
def seed_inboxes
|
||||
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
|
||||
end
|
||||
|
||||
def seed_csat_responses
|
||||
Seeders::CsatSeeder.new(account: @account).perform!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
## Class to generate sample conversations for a chatwoot test @Account.
|
||||
############################################################
|
||||
### Usage #####
|
||||
#
|
||||
# # Seed an account with conversations
|
||||
# Seeders::ConversationSeeder.new(account: Account.find(1), contact_data: []).perform!
|
||||
#
|
||||
#
|
||||
############################################################
|
||||
|
||||
class Seeders::ConversationSeeder
|
||||
def initialize(account:, contact_data:)
|
||||
raise 'Conversation Seeding is not allowed in production.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
|
||||
|
||||
@account = account
|
||||
@contact_data = contact_data
|
||||
end
|
||||
|
||||
def perform!
|
||||
seed_conversations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def seed_conversations
|
||||
@contact_data.each do |contact_data|
|
||||
contact = @account.contacts.find_by(email: contact_data['email'])
|
||||
next unless contact
|
||||
|
||||
contact_data['conversations'].each do |conversation_data|
|
||||
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
|
||||
contact_inbox = inbox.contact_inboxes.create_or_find_by!(
|
||||
contact: contact,
|
||||
source_id: (conversation_data['source_id'] || SecureRandom.hex)
|
||||
)
|
||||
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation(contact_inbox:, conversation_data:)
|
||||
assignee = determine_assignee(conversation_data, contact_inbox.inbox)
|
||||
conversation = build_conversation(contact_inbox, assignee)
|
||||
|
||||
create_messages(conversation: conversation, messages: conversation_data['messages'])
|
||||
apply_conversation_attributes(conversation, conversation_data)
|
||||
resolve_conversation_if_needed(conversation, assignee)
|
||||
end
|
||||
|
||||
def determine_assignee(conversation_data, inbox)
|
||||
assignee = User.from_email(conversation_data['assignee']) if conversation_data['assignee'].present?
|
||||
assignee ||= @account.agents.sample if inbox.csat_survey_enabled
|
||||
assignee
|
||||
end
|
||||
|
||||
def build_conversation(contact_inbox, assignee)
|
||||
contact_inbox.conversations.create!(
|
||||
account: contact_inbox.inbox.account,
|
||||
contact: contact_inbox.contact,
|
||||
inbox: contact_inbox.inbox,
|
||||
assignee: assignee
|
||||
)
|
||||
end
|
||||
|
||||
def apply_conversation_attributes(conversation, conversation_data)
|
||||
conversation.update_labels(conversation_data[:labels]) if conversation_data[:labels].present?
|
||||
conversation.update!(priority: conversation_data[:priority]) if conversation_data[:priority].present?
|
||||
end
|
||||
|
||||
def resolve_conversation_if_needed(conversation, assignee)
|
||||
return unless assignee.present? && rand > 0.4
|
||||
|
||||
conversation.update!(status: :resolved)
|
||||
end
|
||||
|
||||
def create_messages(conversation:, messages:)
|
||||
messages.each do |message_data|
|
||||
sender = find_message_sender(conversation, message_data)
|
||||
conversation.messages.create!(
|
||||
message_data.slice('content', 'message_type').merge(
|
||||
account: conversation.inbox.account, sender: sender, inbox: conversation.inbox
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def find_message_sender(conversation, message_data)
|
||||
if message_data['message_type'] == 'incoming'
|
||||
conversation.contact
|
||||
elsif message_data['sender'].present?
|
||||
User.from_email(message_data['sender'])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,88 +0,0 @@
|
||||
## Class to generate sample CSAT survey responses for a chatwoot test @Account.
|
||||
############################################################
|
||||
### Usage #####
|
||||
#
|
||||
# # Seed an account with CSAT responses
|
||||
# Seeders::CsatSeeder.new(account: Account.find(1)).perform!
|
||||
#
|
||||
#
|
||||
############################################################
|
||||
|
||||
class Seeders::CsatSeeder
|
||||
def initialize(account:)
|
||||
raise 'CSAT Seeding is not allowed in production.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
|
||||
|
||||
@account = account
|
||||
end
|
||||
|
||||
def perform!
|
||||
seed_csat_responses
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def seed_csat_responses
|
||||
resolved_conversations = @account.conversations.where(status: :resolved)
|
||||
csat_enabled_inboxes = @account.inboxes.where(csat_survey_enabled: true)
|
||||
|
||||
return if resolved_conversations.empty? || csat_enabled_inboxes.empty?
|
||||
|
||||
# Create CSAT responses for resolved conversations in CSAT-enabled inboxes
|
||||
resolved_conversations.joins(:inbox).where(inbox: csat_enabled_inboxes).find_each do |conversation|
|
||||
# Only create CSAT for about 70% of resolved conversations to simulate real-world scenarios
|
||||
next if rand > 0.7
|
||||
|
||||
# Get the last outgoing message to attach CSAT to
|
||||
last_outgoing_message = conversation.messages.where(message_type: :outgoing).last
|
||||
next unless last_outgoing_message
|
||||
|
||||
# Create CSAT survey response with varied ratings and feedback
|
||||
create_csat_response(conversation, last_outgoing_message)
|
||||
end
|
||||
end
|
||||
|
||||
def create_csat_response(conversation, message)
|
||||
# Generate realistic rating distribution (more positive ratings)
|
||||
rating = case rand
|
||||
when 0.0..0.05 then 1 # 5%
|
||||
when 0.05..0.15 then 2 # 10%
|
||||
when 0.15..0.35 then 3 # 20%
|
||||
when 0.35..0.65 then 4 # 30%
|
||||
else 5 # 35%
|
||||
end
|
||||
|
||||
feedback_messages = [
|
||||
'Great service, very helpful!',
|
||||
'Quick response and resolved my issue',
|
||||
'Professional and courteous support',
|
||||
'Could be faster but overall good',
|
||||
'Satisfied with the resolution',
|
||||
'Excellent customer service',
|
||||
'Very knowledgeable agent',
|
||||
'Resolved quickly, thank you!',
|
||||
'Good experience overall',
|
||||
'Agent was very patient and helpful',
|
||||
'Really appreciate the help',
|
||||
'Fast and efficient service',
|
||||
'Agent went above and beyond',
|
||||
'Clear explanations provided',
|
||||
'Issue resolved on first contact',
|
||||
'', # Some responses have no feedback
|
||||
'',
|
||||
''
|
||||
]
|
||||
|
||||
CsatSurveyResponse.create!(
|
||||
account: @account,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
message: message,
|
||||
assigned_agent: conversation.assignee,
|
||||
rating: rating,
|
||||
feedback_message: feedback_messages.sample,
|
||||
created_at: message.created_at + rand(5..30).minutes
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug { "Failed to create CSAT response: #{e.message}" }
|
||||
end
|
||||
end
|
||||
@@ -30,31 +30,13 @@ class Seeders::InboxSeeder
|
||||
|
||||
def seed_website_inbox
|
||||
channel = Channel::WebWidget.create!(account: @account, website_url: "https://#{@company_data['domain']}")
|
||||
Inbox.create!(
|
||||
channel: channel,
|
||||
account: @account,
|
||||
name: "#{@company_data['name']} Website",
|
||||
csat_survey_enabled: true,
|
||||
csat_config: {
|
||||
'display_type' => 'emoji',
|
||||
'message' => 'How would you rate your experience?'
|
||||
}
|
||||
)
|
||||
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Website")
|
||||
end
|
||||
|
||||
def seed_facebook_inbox
|
||||
channel = Channel::FacebookPage.create!(account: @account, user_access_token: SecureRandom.hex, page_access_token: SecureRandom.hex,
|
||||
page_id: SecureRandom.hex)
|
||||
Inbox.create!(
|
||||
channel: channel,
|
||||
account: @account,
|
||||
name: "#{@company_data['name']} Facebook",
|
||||
csat_survey_enabled: true,
|
||||
csat_config: {
|
||||
'display_type' => 'star',
|
||||
'message' => 'Please rate your support experience'
|
||||
}
|
||||
)
|
||||
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Facebook")
|
||||
end
|
||||
|
||||
def seed_twitter_inbox
|
||||
@@ -89,16 +71,7 @@ class Seeders::InboxSeeder
|
||||
def seed_email_inbox
|
||||
channel = Channel::Email.create!(account: @account, email: "test#{SecureRandom.hex}@#{@company_data['domain']}",
|
||||
forward_to_email: "test_fwd#{SecureRandom.hex}@#{@company_data['domain']}")
|
||||
Inbox.create!(
|
||||
channel: channel,
|
||||
account: @account,
|
||||
name: "#{@company_data['name']} Email",
|
||||
csat_survey_enabled: true,
|
||||
csat_config: {
|
||||
'display_type' => 'emoji',
|
||||
'message' => 'How was your support experience today?'
|
||||
}
|
||||
)
|
||||
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Email")
|
||||
end
|
||||
|
||||
def seed_api_inbox
|
||||
|
||||
@@ -477,55 +477,4 @@ contacts:
|
||||
- message_type: outgoing
|
||||
sender: michael_scott@paperlayer.test
|
||||
content: How may i help you ?
|
||||
- name: "Robert Maxwell"
|
||||
email: "rmaxwell@example.test"
|
||||
gender: 'male'
|
||||
conversations:
|
||||
- channel: Channel::WebWidget
|
||||
assignee: karn@paperlayer.test
|
||||
messages:
|
||||
- message_type: incoming
|
||||
content: "Hi, I need help with my account settings."
|
||||
- message_type: outgoing
|
||||
sender: karn@paperlayer.test
|
||||
content: "I'd be happy to help you with your account settings. What specifically would you like to change?"
|
||||
- message_type: incoming
|
||||
content: "I want to update my billing information."
|
||||
- message_type: outgoing
|
||||
sender: karn@paperlayer.test
|
||||
content: "Perfect! I've sent you a secure link to update your billing information. Please check your email."
|
||||
- name: "Sarah Johnson"
|
||||
email: "sjohnson@demo.test"
|
||||
gender: 'female'
|
||||
conversations:
|
||||
- channel: Channel::FacebookPage
|
||||
assignee: danny@paperlayer.test
|
||||
messages:
|
||||
- message_type: incoming
|
||||
content: "Hello, I'm having trouble with the mobile app."
|
||||
- message_type: outgoing
|
||||
sender: danny@paperlayer.test
|
||||
content: "Sorry to hear that! Can you tell me what specific issue you're experiencing?"
|
||||
- message_type: incoming
|
||||
content: "The app keeps crashing when I try to upload a document."
|
||||
- message_type: outgoing
|
||||
sender: danny@paperlayer.test
|
||||
content: "I understand how frustrating that must be. Let me help you troubleshoot this issue right away."
|
||||
- name: "David Chen"
|
||||
email: "dchen@sample.test"
|
||||
gender: 'male'
|
||||
conversations:
|
||||
- channel: Channel::Email
|
||||
source_id: "dchen@sample.test"
|
||||
assignee: ben@paperlayer.test
|
||||
messages:
|
||||
- message_type: incoming
|
||||
content: "I need assistance with integrating your API."
|
||||
- message_type: outgoing
|
||||
sender: ben@paperlayer.test
|
||||
content: "I'll be happy to help with the API integration. What programming language are you using?"
|
||||
- message_type: incoming
|
||||
content: "We're using Python for our backend."
|
||||
- message_type: outgoing
|
||||
sender: ben@paperlayer.test
|
||||
content: "Great! I'll send you our Python SDK documentation and some sample code to get you started."
|
||||
sender: michael_scott@paperlayer.test
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Notion Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
# Validate URL components
|
||||
url = response.parsed_body['url']
|
||||
uri = URI.parse(url)
|
||||
params = CGI.parse(uri.query)
|
||||
|
||||
expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(params['response_type']).to eq(['code'])
|
||||
expect(params['owner']).to eq(['user'])
|
||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
|
||||
|
||||
# Validate state parameter exists and can be decoded back to the account
|
||||
expect(params['state']).to be_present
|
||||
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
|
||||
expect(decoded_account).to eq(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotionConcern, type: :concern do
|
||||
let(:controller_class) do
|
||||
Class.new do
|
||||
include NotionConcern
|
||||
end
|
||||
end
|
||||
|
||||
let(:controller) { controller_class.new }
|
||||
|
||||
describe '#notion_client' do
|
||||
let(:client_id) { 'test_notion_client_id' }
|
||||
let(:client_secret) { 'test_notion_client_secret' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
|
||||
end
|
||||
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
expect(OAuth2::Client).to receive(:new).with(
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
site: 'https://api.notion.com',
|
||||
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
|
||||
token_url: 'https://api.notion.com/v1/oauth/token',
|
||||
auth_scheme: :basic_auth
|
||||
}
|
||||
)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'loads client credentials from GlobalConfigService' do
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'returns OAuth2::Client instance' do
|
||||
client = controller.notion_client
|
||||
expect(client).to be_an_instance_of(OAuth2::Client)
|
||||
end
|
||||
|
||||
it 'configures client with Notion-specific endpoints' do
|
||||
client = controller.notion_client
|
||||
expect(client.site).to eq('https://api.notion.com')
|
||||
expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
|
||||
expect(client.options[:auth_scheme]).to eq(:basic_auth)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notion::CallbacksController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:state) { account.to_sgid.to_s }
|
||||
let(:oauth_code) { 'test_oauth_code' }
|
||||
let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" }
|
||||
|
||||
let(:notion_response_body) do
|
||||
{
|
||||
'access_token' => 'notion_access_token_123',
|
||||
'token_type' => 'bearer',
|
||||
'workspace_name' => 'Test Workspace',
|
||||
'workspace_id' => 'workspace_123',
|
||||
'workspace_icon' => 'https://notion.so/icon.png',
|
||||
'bot_id' => 'bot_123',
|
||||
'owner' => {
|
||||
'type' => 'user',
|
||||
'user' => {
|
||||
'id' => 'user_123',
|
||||
'name' => 'Test User'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
describe 'GET /notion/callback' do
|
||||
before do
|
||||
account.enable_features('notion_integration')
|
||||
stub_const('ENV', ENV.to_hash.merge(
|
||||
'FRONTEND_URL' => 'http://localhost:3000',
|
||||
'NOTION_CLIENT_ID' => 'test_client_id',
|
||||
'NOTION_CLIENT_SECRET' => 'test_client_secret'
|
||||
))
|
||||
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
end
|
||||
|
||||
context 'when OAuth callback is successful' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: notion_response_body.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new integration hook' do
|
||||
expect do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
end.to change(Integrations::Hook, :count).by(1)
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.access_token).to eq('notion_access_token_123')
|
||||
expect(hook.app_id).to eq('notion')
|
||||
expect(hook.status).to eq('enabled')
|
||||
end
|
||||
|
||||
it 'sets correct hook attributes' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.account).to eq(account)
|
||||
expect(hook.app_id).to eq('notion')
|
||||
expect(hook.access_token).to eq('notion_access_token_123')
|
||||
expect(hook.status).to eq('enabled')
|
||||
end
|
||||
|
||||
it 'stores notion workspace data in settings' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.settings['token_type']).to eq('bearer')
|
||||
expect(hook.settings['workspace_name']).to eq('Test Workspace')
|
||||
expect(hook.settings['workspace_id']).to eq('workspace_123')
|
||||
expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
|
||||
expect(hook.settings['bot_id']).to eq('bot_123')
|
||||
expect(hook.settings['owner']).to eq(notion_response_body['owner'])
|
||||
end
|
||||
|
||||
it 'handles successful callback and creates hook' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
# Due to controller mocking limitations in test,
|
||||
# the redirect URL construction fails but hook creation succeeds
|
||||
expect(Integrations::Hook.last.app_id).to eq('notion')
|
||||
expect(response).to be_redirect
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OAuth token request fails' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
|
||||
.to_return(
|
||||
status: 400,
|
||||
body: { error: 'invalid_grant' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'redirects to home page on error' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
expect(response).to redirect_to('/')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: valid_params[:message_content],
|
||||
message_history: valid_params[:message_history]
|
||||
valid_params[:message_content],
|
||||
valid_params[:message_history]
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: params_without_history[:message_content],
|
||||
message_history: []
|
||||
params_without_history[:message_content],
|
||||
[]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,30 +30,5 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
context 'when message contains an image' do
|
||||
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
|
||||
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
|
||||
|
||||
before do
|
||||
image_attachment
|
||||
end
|
||||
|
||||
it 'includes image URL directly in the message content for OpenAI vision analysis' do
|
||||
# Expect the generate_response to receive multimodal content with image URL
|
||||
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
|
||||
history = kwargs[:message_history]
|
||||
last_entry = history.last
|
||||
expect(last_entry[:content]).to be_an(Array)
|
||||
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
|
||||
expect(last_entry[:content].any? do |part|
|
||||
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
|
||||
end).to be true
|
||||
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::OpenAiMessageBuilderService do
|
||||
subject(:service) { described_class.new(message: message) }
|
||||
|
||||
let(:message) { create(:message, content: 'Hello world') }
|
||||
|
||||
describe '#generate_content' do
|
||||
context 'when message has only text content' do
|
||||
it 'returns the text content directly' do
|
||||
expect(service.generate_content).to eq('Hello world')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content and no attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
it 'returns default message' do
|
||||
expect(service.generate_content).to eq('Message without content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has text content and attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'text', text: 'Hello world' })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has only non-text attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts without text' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attachment_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
let(:attachments) { message.attachments }
|
||||
|
||||
context 'with image attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes image parts' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with audio attachments' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes transcription text part' do
|
||||
audio_attachment # trigger creation
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with other file types' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes generic attachment message' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed attachment types' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:document_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes all relevant parts' do
|
||||
image_attachment # trigger creation
|
||||
audio_attachment # trigger creation
|
||||
document_attachment # trigger creation
|
||||
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).to include({ type: 'text', text: 'Audio text' })
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with valid image attachments' do
|
||||
let(:image1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:image2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
it 'returns image parts for all valid images' do
|
||||
image1 # trigger creation
|
||||
image2 # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with image attachments without URLs' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'skips images without valid URLs' do
|
||||
image_attachment # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_attachment_url' do
|
||||
let(:attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
context 'when attachment has external_url' do
|
||||
before { attachment.update(external_url: 'https://example.com/image.jpg') }
|
||||
|
||||
it 'returns external_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has attached file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
|
||||
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
|
||||
end
|
||||
|
||||
it 'returns file_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has no URL or file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_audio_transcriptions' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with no audio attachments' do
|
||||
it 'returns empty string' do
|
||||
result = service.send(:extract_audio_transcriptions, message.attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with successful audio transcriptions' do
|
||||
let(:audio1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
|
||||
)
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'concatenates all successful transcriptions' do
|
||||
audio1 # trigger creation
|
||||
audio2 # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('First audio text. Second audio text.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with failed audio transcriptions' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty string for failed transcriptions' do
|
||||
audio_attachment # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private helper methods' do
|
||||
describe '#text_part' do
|
||||
it 'returns correct text part format' do
|
||||
result = service.send(:text_part, 'Hello world')
|
||||
expect(result).to eq({ type: 'text', text: 'Hello world' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_part' do
|
||||
it 'returns correct image part format' do
|
||||
result = service.send(:image_part, 'https://example.com/image.jpg')
|
||||
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -66,6 +66,34 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
describe '#reply_created' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
|
||||
def create_customer_message(conversation, created_at: Time.current)
|
||||
create(:message,
|
||||
message_type: 'incoming',
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: contact,
|
||||
created_at: created_at)
|
||||
end
|
||||
|
||||
def create_agent_message(conversation, created_at: Time.current, sender: user)
|
||||
create(:message,
|
||||
message_type: 'outgoing',
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: sender,
|
||||
created_at: created_at)
|
||||
end
|
||||
|
||||
def create_reply_event(agent_message, waiting_since, event_time = nil)
|
||||
Events::Base.new('reply.created', event_time || agent_message.created_at,
|
||||
waiting_since: waiting_since,
|
||||
message: agent_message)
|
||||
end
|
||||
|
||||
it 'creates reply created event' do
|
||||
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
|
||||
listener.reply_created(event)
|
||||
@@ -74,6 +102,88 @@ describe ReportingEventListener do
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(1).of(7200)
|
||||
end
|
||||
|
||||
context 'when conversation is reopened' do
|
||||
let(:resolved_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user,
|
||||
status: 'resolved', contact: contact)
|
||||
end
|
||||
|
||||
context 'when customer sends message after resolution' do
|
||||
it 'calculates reply time from the reopening message' do
|
||||
customer_message_time = 3.hours.ago
|
||||
create_customer_message(resolved_conversation, created_at: customer_message_time)
|
||||
|
||||
resolved_conversation.reload
|
||||
expect(resolved_conversation.status).to eq('open')
|
||||
|
||||
agent_reply_time = 1.hour.ago
|
||||
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
|
||||
|
||||
event = create_reply_event(agent_message, customer_message_time)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(60).of(7200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has multiple reopenings' do
|
||||
it 'tracks reply time correctly for each reopening' do
|
||||
create_customer_message(resolved_conversation, created_at: 5.hours.ago)
|
||||
first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago)
|
||||
|
||||
event = create_reply_event(first_agent_reply, 5.hours.ago)
|
||||
listener.reply_created(event)
|
||||
|
||||
resolved_conversation.update!(status: 'resolved')
|
||||
|
||||
create_customer_message(resolved_conversation, created_at: 2.hours.ago)
|
||||
second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago)
|
||||
|
||||
event = create_reply_event(second_agent_reply, 2.hours.ago)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
.order(created_at: :asc)
|
||||
expect(events.length).to be 2
|
||||
expect(events.first.value).to be_within(60).of(3600)
|
||||
expect(events.second.value).to be_within(60).of(1800)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is manually reopened' do
|
||||
it 'sets waiting_since when first customer message arrives after manual reopening' do
|
||||
resolved_conversation.update!(status: 'open')
|
||||
|
||||
customer_message_time = 1.hour.ago
|
||||
create_customer_message(resolved_conversation, created_at: customer_message_time)
|
||||
|
||||
agent_reply_time = 15.minutes.ago
|
||||
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
|
||||
|
||||
event = create_reply_event(agent_message, customer_message_time)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(60).of(2700)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waiting_since is nil' do
|
||||
it 'does not creates reply time events' do
|
||||
agent_message = create_agent_message(resolved_conversation)
|
||||
|
||||
event = create_reply_event(agent_message, nil)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#first_reply_created' do
|
||||
|
||||
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
|
||||
expect(message_window_service).to have_received(:can_reply?)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'reply time calculation flows' do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
|
||||
let(:conversation_start_time) { 5.hours.ago }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_column(:waiting_since, nil)
|
||||
conversation.update_column(:created_at, conversation_start_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
conversation.messages.destroy_all
|
||||
conversation.reporting_events.destroy_all
|
||||
conversation.reload
|
||||
end
|
||||
|
||||
def create_customer_message(conversation, created_at: Time.current)
|
||||
message = nil
|
||||
perform_enqueued_jobs do
|
||||
message = create(:message,
|
||||
message_type: 'incoming',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
sender: conversation.contact,
|
||||
created_at: created_at)
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
def create_agent_message(conversation, created_at: Time.current)
|
||||
message = nil
|
||||
perform_enqueued_jobs do
|
||||
message = create(:message,
|
||||
message_type: 'outgoing',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
sender: conversation.assignee,
|
||||
created_at: created_at)
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
it 'correctly tracks waiting_since and creates first response time events' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
|
||||
# Agent replies - this should create first response event
|
||||
agent_reply1_time = 4.hours.ago
|
||||
create_agent_message(conversation, created_at: agent_reply1_time)
|
||||
|
||||
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
|
||||
expect(first_response_events.count).to eq(1)
|
||||
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
|
||||
|
||||
# the first response should also clear the waiting_since
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
it 'does not reset waiting_since if customer sends another message' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
end
|
||||
|
||||
it 'records the correct reply_time for subsequent messages' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
create_agent_message(conversation, created_at: 4.hours.ago)
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
|
||||
create_agent_message(conversation, created_at: 2.hours.ago)
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(1)
|
||||
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
it 'records zero reply time if an agent sends a message after resolution' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
create_agent_message(conversation, created_at: 4.hours.ago)
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
|
||||
conversation.toggle_status
|
||||
expect(conversation.status).to eq('resolved')
|
||||
|
||||
conversation.toggle_status
|
||||
expect(conversation.status).to eq('open')
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
create_agent_message(conversation, created_at: 1.hour.ago)
|
||||
# update_waiting_since will ensure that no events were created since the waiting_since was nil
|
||||
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user