Compare commits

..
Author SHA1 Message Date
Pranav 4521d44a07 Revert "feat: Add support for image files in Captain (#11730)"
This reverts commit 811eb66615.
2025-06-28 16:54:24 -07:00
b7f3f72b9c fix: Reply time calculation for re-opened conversations (#11787)
This PR fixes the reply time calculation for reopened conversations.
Previously, when a customer sent a message to reopen a resolved
conversation, the reply time metric would be calculated incorrectly
because the `waiting_since` timestamp was not properly set before the
reply event was dispatched. This would create a case where you'd have
reporting events like the following

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

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

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

The changes have been tested with comprehensive specs that verify:

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

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

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-27 10:48:07 +05:30
b26862e3d8 feat: notion OAuth setup (#11765)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-26 19:16:06 +05:30
39 changed files with 806 additions and 690 deletions
@@ -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>
@@ -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(
+10
View File
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
private
def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
def handle_resolved_status_change
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
return unless saved_change_to_status? && status == 'resolved'
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
+13 -1
View File
@@ -55,9 +55,11 @@ class Integrations::App
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
shopify_enabled?(account)
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'notion'
notion_enabled?(account)
else
true
end
@@ -113,4 +115,14 @@ class Integrations::App
all.detect { |app| app.id == params[:id] }
end
end
private
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
def notion_enabled?(account)
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
end
end
+4
View File
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
def notion?
app_id == 'notion'
end
def disable
update(status: 'disabled')
end
@@ -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

+3
View File
@@ -173,3 +173,6 @@
display_name: Voice Channel
enabled: false
chatwoot_internal: true
- name: notion_integration
display_name: Notion Integration
enabled: false
+19
View File
@@ -288,6 +288,25 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Notion ------ ##
- name: NOTION_CLIENT_ID
display_title: 'Notion Client ID'
value:
locked: false
description: 'Notion client ID'
- name: NOTION_CLIENT_SECRET
display_title: 'Notion Client Secret'
value:
locked: false
description: 'Notion client secret'
type: secret
- name: NOTION_VERSION
display_title: 'Notion Version'
value: '2022-06-28'
locked: false
description: 'Notion version'
## ------ End of Configs added for Notion ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
+6
View File
@@ -63,6 +63,12 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
notion:
id: notion
logo: notion.png
i18n_key: notion
hook_type: account
allow_multiple_hooks: false
slack:
id: slack
logo: slack.png
+4
View File
@@ -257,6 +257,10 @@ en:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
+10
View File
@@ -228,6 +228,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :notion do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -265,6 +269,11 @@ Rails.application.routes.draw do
get :linked_issues
end
end
resource :notion, controller: 'notion', only: [] do
collection do
delete :destroy
end
end
end
resources :working_hours, only: [:update]
@@ -493,6 +502,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'notion/callback', to: 'notion/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
@@ -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
+32 -7
View File
@@ -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
-94
View File
@@ -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
-88
View File
@@ -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
+3 -30
View File
@@ -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
+1 -52
View File
@@ -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
+113
View File
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
expect(message_window_service).to have_received(:can_reply?)
end
end
describe 'reply time calculation flows' do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
let(:conversation_start_time) { 5.hours.ago }
before do
create(:inbox_member, user: agent, inbox: inbox)
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:waiting_since, nil)
conversation.update_column(:created_at, conversation_start_time)
# rubocop:enable Rails/SkipsModelValidations
conversation.messages.destroy_all
conversation.reporting_events.destroy_all
conversation.reload
end
def create_customer_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'incoming',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: conversation.contact,
created_at: created_at)
end
message
end
def create_agent_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: conversation.assignee,
created_at: created_at)
end
message
end
it 'correctly tracks waiting_since and creates first response time events' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
# Agent replies - this should create first response event
agent_reply1_time = 4.hours.ago
create_agent_message(conversation, created_at: agent_reply1_time)
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
expect(first_response_events.count).to eq(1)
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
# the first response should also clear the waiting_since
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'does not reset waiting_since if customer sends another message' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
end
it 'records the correct reply_time for subsequent messages' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
create_agent_message(conversation, created_at: 2.hours.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1)
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'records zero reply time if an agent sends a message after resolution' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.toggle_status
expect(conversation.status).to eq('resolved')
conversation.toggle_status
expect(conversation.status).to eq('open')
conversation.reload
expect(conversation.waiting_since).to be_nil
create_agent_message(conversation, created_at: 1.hour.ago)
# update_waiting_since will ensure that no events were created since the waiting_since was nil
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
end
end