Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98eae0b2d | ||
|
|
e86f9c3005 | ||
|
|
66ce599e4e | ||
|
|
17500cc62d | ||
|
|
b9d3a34a73 | ||
|
|
50d023caed | ||
|
|
9007bf1ecf | ||
|
|
5140deb6f6 | ||
|
|
3e5ca9bca9 | ||
|
|
358ec8120b | ||
|
|
5ebe8c71ec | ||
|
|
c73f33619d | ||
|
|
8c78573d9d | ||
|
|
c553997af8 | ||
|
|
c8f0cbbfa1 | ||
|
|
30a3a35281 | ||
|
|
340fcb6152 | ||
|
|
cacd192714 | ||
|
|
b531dd0461 | ||
|
|
cb3721a425 | ||
|
|
23ed1cc17f | ||
|
|
d32d47b41b | ||
|
|
a1a3f62d1b | ||
|
|
ccff83e253 | ||
|
|
fdbf8b52f3 | ||
|
|
8544c87cfd | ||
|
|
b04ae3e251 | ||
|
|
f0f3e615b3 | ||
|
|
a1ce805fa5 | ||
|
|
e326a7a8c8 | ||
|
|
be8a441bfc | ||
|
|
212f07711d | ||
|
|
e6012d7e86 | ||
|
|
9d86792468 | ||
|
|
6e1c3ee176 | ||
|
|
2f8214eb87 | ||
|
|
48293001ed | ||
|
|
83cd7b6788 | ||
|
|
65bb584f48 | ||
|
|
9ad1b219a3 | ||
|
|
f812ca3b99 | ||
|
|
c6df54cc29 | ||
|
|
384e616c1b | ||
|
|
34a2eff135 | ||
|
|
1c09cc5aa5 | ||
|
|
5bf5a0e0d0 | ||
|
|
e3d76fa622 | ||
|
|
4f123e68a8 | ||
|
|
27284dd69e | ||
|
|
a5321376e7 | ||
|
|
20f63cf957 | ||
|
|
712054a8b7 | ||
|
|
7a4dfd5d4b | ||
|
|
a17b7815e3 | ||
|
|
8025cd5aab | ||
|
|
7319d822fd | ||
|
|
23a06739fc | ||
|
|
0771d0f269 | ||
|
|
eb6c4a258a | ||
|
|
317a8ee4d5 | ||
|
|
4a7a68e6e7 | ||
|
|
4c21a8d7b4 | ||
|
|
71ddd6c2c2 | ||
|
|
34852fccc1 | ||
|
|
149085e3d8 | ||
|
|
e0b52c3fc2 | ||
|
|
806ddaa9ab | ||
|
|
f24514433b | ||
|
|
700ce0540a | ||
|
|
7cc082e586 | ||
|
|
392fa50aee | ||
|
|
3582d47299 | ||
|
|
06e4708e12 | ||
|
|
9e40a79958 | ||
|
|
947e4b59e8 | ||
|
|
8816e8651b | ||
|
|
f429db89b4 | ||
|
|
203c010846 | ||
|
|
e1fdc30c31 | ||
|
|
1eca659b93 | ||
|
|
7a70cea36b | ||
|
|
7c4e8346f8 | ||
|
|
e4cd585513 | ||
|
|
9e5e90c258 | ||
|
|
c8a66b9ebb | ||
|
|
454686ab5d |
@@ -0,0 +1,28 @@
|
||||
name: Auto-assign PR to Author
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Auto-assign PR to author
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
assignees: [author]
|
||||
});
|
||||
|
||||
console.log(`Assigned PR #${pull_number} to ${author}`);
|
||||
@@ -174,8 +174,6 @@ gem 'neighbor'
|
||||
gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
# PDF text extraction
|
||||
gem 'pdf-reader', '~> 2.0'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
|
||||
+1
-14
@@ -25,7 +25,6 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
Ascii85 (2.0.1)
|
||||
actioncable (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
@@ -127,7 +126,6 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
afm (0.2.2)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -367,7 +365,6 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashery (2.1.2)
|
||||
hashie (5.0.0)
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
@@ -557,12 +554,6 @@ GEM
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pdf-reader (2.14.1)
|
||||
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
|
||||
afm (~> 0.2.1)
|
||||
hashery (~> 2.0)
|
||||
ruby-rc4
|
||||
ttfunk
|
||||
pg (1.5.3)
|
||||
pg_search (2.3.6)
|
||||
activerecord (>= 5.2)
|
||||
@@ -720,7 +711,6 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-rc4 (0.1.5)
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -829,8 +819,6 @@ GEM
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
ttfunk (1.8.0)
|
||||
bigdecimal (~> 3.1)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
@@ -975,7 +963,6 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
pdf-reader (~> 2.0)
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -1037,4 +1024,4 @@ RUBY VERSION
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
2.5.16
|
||||
|
||||
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -39,8 +39,9 @@ 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]
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
class Whatsapp::EmbeddedController < ApplicationController
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
|
||||
def new
|
||||
# Configuration endpoint for embedded signup initialization
|
||||
render json: {
|
||||
status: 'ready',
|
||||
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
app_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
}
|
||||
end
|
||||
|
||||
def embedded_signup
|
||||
# Complete embedded signup using auth code + business info from frontend
|
||||
validate_authorization_code!
|
||||
return if performed?
|
||||
|
||||
validate_required_parameters!
|
||||
return if performed?
|
||||
|
||||
channel = process_signup
|
||||
@inbox = channel.inbox
|
||||
|
||||
# Return the inbox object using the standard jbuilder template
|
||||
render 'embedded_signup', formats: [:json]
|
||||
rescue StandardError => e
|
||||
handle_signup_error(e)
|
||||
end
|
||||
|
||||
def reauthorize
|
||||
# Reauthorize existing WhatsApp inbox using embedded signup flow
|
||||
validate_authorization_code!
|
||||
return if performed?
|
||||
|
||||
validate_required_parameters!
|
||||
return if performed?
|
||||
|
||||
validate_inbox_id!
|
||||
return if performed?
|
||||
|
||||
channel = process_reauthorization
|
||||
@inbox = channel.inbox
|
||||
|
||||
# Clear reauthorization required flag
|
||||
channel.reauthorized!
|
||||
|
||||
render json: { message: 'WhatsApp channel reauthorized successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
handle_signup_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_authorization_code!
|
||||
return if params[:code].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing authorization code',
|
||||
message: 'Authorization code is required for embedded signup'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def validate_required_parameters!
|
||||
return if params[:business_id].present? && params[:waba_id].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing required parameters: business_id and waba_id are required'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def validate_inbox_id!
|
||||
return if params[:inbox_id].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing inbox_id parameter'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def process_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
{
|
||||
account: Current.account,
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_id]
|
||||
}
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
def process_reauthorization
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
{
|
||||
account: Current.account,
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_id],
|
||||
inbox_id: params[:inbox_id]
|
||||
}
|
||||
)
|
||||
|
||||
service.perform_reauthorization
|
||||
end
|
||||
|
||||
def handle_signup_error(error)
|
||||
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
render json: {
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Current.account, :update?)
|
||||
end
|
||||
end
|
||||
@@ -15,14 +15,6 @@ class CaptainDocument extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
uploadPdf(formData) {
|
||||
return axios.post(`${this.url}/upload_pdf`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+11
-26
@@ -2,43 +2,28 @@
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DocumentForm from './DocumentForm.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const documentForm = ref(null);
|
||||
|
||||
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
|
||||
const handleSubmit = async newDocument => {
|
||||
try {
|
||||
if (newDocument.type === 'pdf') {
|
||||
// Handle PDF upload
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_document', newDocument.pdf_document);
|
||||
formData.append('assistant_id', newDocument.assistant_id);
|
||||
|
||||
await store.dispatch('captainDocuments/uploadPdf', formData);
|
||||
useAlert('PDF uploaded successfully! Processing will begin shortly.');
|
||||
} else {
|
||||
// Handle URL creation
|
||||
await store.dispatch('captainDocuments/create', {
|
||||
document: {
|
||||
external_link: newDocument.external_link,
|
||||
assistant_id: newDocument.assistant_id,
|
||||
},
|
||||
});
|
||||
useAlert('Document created successfully!');
|
||||
}
|
||||
|
||||
// Emit success event to refresh the list
|
||||
emit('success');
|
||||
await store.dispatch('captainDocuments/create', newDocument);
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.message ||
|
||||
'Failed to create document. Please try again.';
|
||||
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
@@ -57,8 +42,8 @@ defineExpose({ dialogRef });
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t('CAPTAIN.DOCUMENTS.CREATE.TITLE')"
|
||||
:description="$t('CAPTAIN.DOCUMENTS.CREATE.DESCRIPTION')"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
|
||||
+31
-227
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, url } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -7,36 +8,27 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import FileUpload from 'dashboard/components-next/fileUpload/FileUpload.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
url: '',
|
||||
name: '',
|
||||
assistantId: null,
|
||||
selectedFile: null,
|
||||
sourceType: 'url', // 'url' or 'pdf'
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const uploadError = ref('');
|
||||
|
||||
const validationRules = computed(() => {
|
||||
const rules = {
|
||||
assistantId: { required },
|
||||
};
|
||||
|
||||
if (state.sourceType === 'url') {
|
||||
rules.url = { required, url, minLength: minLength(1) };
|
||||
}
|
||||
|
||||
return rules;
|
||||
});
|
||||
const validationRules = {
|
||||
url: { required, url, minLength: minLength(1) },
|
||||
assistantId: { required },
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
@@ -49,258 +41,70 @@ const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const getErrorMessage = (field, errorMessage) => {
|
||||
return v$.value[field]?.$error ? errorMessage : '';
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'Please enter a valid URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'Please select an assistant'),
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareDocumentDetails = () => {
|
||||
if (state.sourceType === 'pdf') {
|
||||
return {
|
||||
type: 'pdf',
|
||||
pdf_document: state.selectedFile,
|
||||
assistant_id: state.assistantId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
};
|
||||
};
|
||||
const prepareDocumentDetails = () => ({
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
uploadError.value = '';
|
||||
|
||||
// Validate form
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For PDF uploads, check if file is selected
|
||||
if (state.sourceType === 'pdf' && !state.selectedFile) {
|
||||
uploadError.value = 'Please select a PDF file to upload.';
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', prepareDocumentDetails());
|
||||
};
|
||||
|
||||
// PDF Upload handlers
|
||||
const handleFileSelected = file => {
|
||||
state.selectedFile = file;
|
||||
uploadError.value = '';
|
||||
};
|
||||
|
||||
const handleFileError = error => {
|
||||
uploadError.value = error;
|
||||
state.selectedFile = null;
|
||||
};
|
||||
|
||||
const clearSelectedFile = () => {
|
||||
state.selectedFile = null;
|
||||
uploadError.value = '';
|
||||
};
|
||||
|
||||
const handleSourceTypeChange = type => {
|
||||
state.sourceType = type;
|
||||
uploadError.value = '';
|
||||
state.selectedFile = null;
|
||||
state.url = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<!-- Source Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.LABEL') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
|
||||
:class="{
|
||||
'ring-2 ring-woot-500 text-woot-500 border-0':
|
||||
state.sourceType === 'url',
|
||||
'border-2 border-n-slate-7 text-n-slate-11':
|
||||
state.sourceType !== 'url',
|
||||
}"
|
||||
@click="handleSourceTypeChange('url')"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12S4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12S18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12S19.76 7 17 7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">{{
|
||||
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.WEBSITE_URL')
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
|
||||
:class="{
|
||||
'ring-2 ring-woot-500 text-woot-500 border-0':
|
||||
state.sourceType === 'pdf',
|
||||
'border-2 border-n-slate-7 text-n-slate-11':
|
||||
state.sourceType !== 'pdf',
|
||||
}"
|
||||
@click="handleSourceTypeChange('pdf')"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">{{
|
||||
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.PDF_UPLOAD')
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL Input (when URL is selected) -->
|
||||
<div v-if="state.sourceType === 'url'">
|
||||
<Input
|
||||
v-model="state.url"
|
||||
label="Website URL"
|
||||
placeholder="Enter the website URL to crawl"
|
||||
:message="formErrors.url"
|
||||
:message-type="formErrors.url ? 'error' : 'info'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PDF Upload (when PDF is selected) -->
|
||||
<div v-if="state.sourceType === 'pdf'" class="flex flex-col gap-2">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.LABEL') }}
|
||||
</label>
|
||||
|
||||
<!-- File upload area -->
|
||||
<div v-if="!state.selectedFile">
|
||||
<FileUpload
|
||||
accept="application/pdf"
|
||||
:max-size-m-b="25"
|
||||
placeholder="Select a PDF file"
|
||||
upload-text="Click to select PDF or drag and drop"
|
||||
drag-text="Drop PDF file here"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-error="handleFileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected file display -->
|
||||
<div v-else class="p-3 bg-n-alpha-2 rounded-lg border border-n-slate-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-red-600"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ state.selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-8">
|
||||
{{
|
||||
Math.round((state.selectedFile.size / 1024 / 1024) * 100) /
|
||||
100
|
||||
}}
|
||||
{{ $t('GENERAL.FILE_SIZE.MB') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-n-alpha-3 text-n-slate-8 hover:text-n-slate-10"
|
||||
@click="clearSelectedFile"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload error -->
|
||||
<p v-if="uploadError" class="text-sm text-red-600">{{ uploadError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Assistant Selection -->
|
||||
<Input
|
||||
v-model="state.url"
|
||||
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
|
||||
:message="formErrors.url"
|
||||
:message-type="formErrors.url ? 'error' : 'info'"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.LABEL') }}
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="assistant"
|
||||
v-model="state.assistantId"
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
placeholder="Choose an assistant"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.assistantId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
label="Cancel"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
label="Create Document"
|
||||
:label="t('CAPTAIN.FORM.CREATE')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import SettingsHeader from './SettingsHeader.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/PageComponents/SettingsHeader"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="px-6 py-4 bg-n-background">
|
||||
<SettingsHeader
|
||||
heading="General Settings"
|
||||
description="Configure general preferences for your workspace."
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Long Description">
|
||||
<div class="px-6 py-4 bg-n-background">
|
||||
<SettingsHeader
|
||||
heading="Integrations"
|
||||
description="Manage and configure third-party integrations such as Slack, Zapier, and Webhooks to enhance your workflow."
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
heading: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex flex-col items-start gap-2">
|
||||
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
|
||||
<p class="text-n-slate-11 text-sm">{{ description }}</p>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,202 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
accept: {
|
||||
type: String,
|
||||
default: '*',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxSizeMB: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
uploadText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dragText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fileSelected', 'fileError']);
|
||||
const fileInput = ref(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const acceptTypes = computed(() => props.accept);
|
||||
const maxSize = computed(() => props.maxSizeMB * 1024 * 1024);
|
||||
|
||||
const handleFileClick = () => {
|
||||
if (!props.disabled) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = file => {
|
||||
// Check file size
|
||||
if (file.size > maxSize.value) {
|
||||
const errorMsg = `File "${file.name}" is too large. Maximum size is ${props.maxSizeMB}MB.`;
|
||||
emit('fileError', errorMsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file type if specified
|
||||
if (
|
||||
props.accept !== '*' &&
|
||||
!file.type.match(new RegExp(props.accept.replace(/\*/g, '.*')))
|
||||
) {
|
||||
const errorMsg = `File type not supported. Only ${props.accept} files are allowed.`;
|
||||
emit('fileError', errorMsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFileChange = event => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (validateFile(file)) {
|
||||
emit('fileSelected', file);
|
||||
}
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragOver = event => {
|
||||
event.preventDefault();
|
||||
if (!props.disabled) {
|
||||
isDragOver.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = event => {
|
||||
event.preventDefault();
|
||||
// Only set to false if we're leaving the drop zone completely
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = event => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (props.disabled) return;
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (validateFile(file)) {
|
||||
emit('fileSelected', file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (isDragOver.value && props.dragText) {
|
||||
return props.dragText;
|
||||
}
|
||||
return props.uploadText || props.placeholder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="file-upload-zone"
|
||||
:class="{
|
||||
'drag-over': isDragOver,
|
||||
disabled: disabled,
|
||||
}"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@click="handleFileClick"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-n-slate-8"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="upload-text">
|
||||
<p class="text-sm font-medium text-n-slate-11">
|
||||
{{ displayText }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-8 mt-1">
|
||||
{{
|
||||
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.MAX_SIZE', {
|
||||
size: maxSizeMB,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-zone {
|
||||
@apply border-2 border-dashed border-n-slate-6 rounded-lg p-6;
|
||||
@apply bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer;
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
@apply flex flex-col items-center justify-center text-center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.file-upload-zone:hover {
|
||||
@apply border-n-slate-7;
|
||||
}
|
||||
|
||||
.file-upload-zone.drag-over {
|
||||
@apply border-n-blue-9 bg-n-blue-2/50;
|
||||
}
|
||||
|
||||
.file-upload-zone.disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
@apply hover:bg-n-alpha-2 hover:border-n-slate-6;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
@apply p-2 rounded-full bg-n-alpha-3;
|
||||
}
|
||||
</style>
|
||||
@@ -133,7 +133,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
@@ -152,7 +156,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="inputType === 'email'"
|
||||
|
||||
@@ -203,7 +203,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
@@ -211,7 +211,11 @@ export default {
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'search_select'"
|
||||
@@ -221,7 +225,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
@@ -229,7 +233,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
|
||||
<input
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
teams,
|
||||
labels,
|
||||
statusFilterOptions,
|
||||
messageTypeOptions,
|
||||
priorityOptions,
|
||||
campaigns,
|
||||
contacts,
|
||||
inboxes,
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
countries,
|
||||
slaPolicies,
|
||||
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
|
||||
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
|
||||
case 'country_code':
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return MESSAGE_CONDITION_VALUES;
|
||||
return messageTypeOptions;
|
||||
case 'priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
|
||||
return [];
|
||||
case 'add_sla':
|
||||
return slaPolicies;
|
||||
case 'change_priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
|
||||
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
|
||||
expect(getConditionDropdownValues('country_code')).toEqual(countries);
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
MESSAGE_CONDITION_VALUES
|
||||
messageTypeOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('gets action dropdown values correctly', () => {
|
||||
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('handles event change correctly', () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
describe('useMacros', () => {
|
||||
const mockLabels = [
|
||||
@@ -148,9 +151,11 @@ describe('useMacros', () => {
|
||||
|
||||
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(
|
||||
PRIORITY_CONDITION_VALUES
|
||||
);
|
||||
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
|
||||
});
|
||||
|
||||
it('returns an empty array for unknown types', () => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
getActionOptions,
|
||||
getConditionOptions,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
|
||||
];
|
||||
});
|
||||
|
||||
const messageTypeOptions = computed(() =>
|
||||
MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a translated "None" option to the beginning of a list
|
||||
* @param {Array} list - The list to add "None" to
|
||||
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
|
||||
customAttributes: getters['attributes/getAttributes'].value,
|
||||
inboxes: inboxes.value,
|
||||
statusFilterOptions: statusFilterOptions.value,
|
||||
priorityOptions: priorityOptions.value,
|
||||
messageTypeOptions: messageTypeOptions.value,
|
||||
teams: teams.value,
|
||||
languages,
|
||||
countries,
|
||||
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
|
||||
languages,
|
||||
type,
|
||||
addNoneToListFn: addNoneToList,
|
||||
priorityOptions: priorityOptions.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
|
||||
booleanFilterOptions,
|
||||
statusFilterItems,
|
||||
statusFilterOptions,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
getConditionDropdownValues,
|
||||
getActionDropdownValues,
|
||||
agents,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useMacros = () => {
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
@@ -32,7 +34,10 @@ export const useMacros = () => {
|
||||
name: i.title,
|
||||
}));
|
||||
case 'change_priority':
|
||||
return PRIORITY_CONDITION_VALUES;
|
||||
return PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const authHeaders = computed(() => {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
key: 'EASY_SETUP',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
|
||||
},
|
||||
{
|
||||
key: 'SECURE_AUTH',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
|
||||
},
|
||||
{
|
||||
key: 'AUTO_CONFIG',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
|
||||
},
|
||||
]);
|
||||
|
||||
const showLoader = computed(
|
||||
() => isAuthenticating.value || isProcessing.value
|
||||
);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
|
||||
if (data.data?.current_step) {
|
||||
message += ` (Step: ${data.data.current_step})`;
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
};
|
||||
|
||||
const handleSignupSuccess = inboxData => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
// Update the store with the new inbox data
|
||||
if (inboxData && inboxData.id) {
|
||||
// Add the new inbox to the store
|
||||
store.commit('inboxes/ADD_INBOXES', inboxData);
|
||||
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Fallback if inbox data is not properly formatted
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send both auth code and business info together (synchronous flow)
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/embedded_signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
authCode.value = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
handleSignupSuccess(responseData);
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleSignupError({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
(businessDataLocal.business_id || businessDataLocal.businessId) &&
|
||||
(businessDataLocal.waba_id || businessDataLocal.wabaId)
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessDataLocal = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessDataLocal) {
|
||||
businessDataLocal = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id:
|
||||
businessDataLocal.business_id || businessDataLocal.businessId,
|
||||
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
|
||||
phone_number_id:
|
||||
businessDataLocal.phone_number_id ||
|
||||
businessDataLocal.phoneNumberId ||
|
||||
businessDataLocal.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
businessData.value = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(normalizedData);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation(data);
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleSignupError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Handle Facebook embedded signup message events
|
||||
try {
|
||||
const originUrl = new URL(event.origin);
|
||||
const allowedHosts = ['facebook.com', 'www.facebook.com'];
|
||||
if (!allowedHosts.includes(originUrl.hostname)) return;
|
||||
} catch (error) {
|
||||
// Invalid origin URL, reject the event
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
};
|
||||
|
||||
// Facebook SDK
|
||||
const loadFacebookSdk = () => {
|
||||
if (window.FB) {
|
||||
fbSdkLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = () => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
if (!window.FB) {
|
||||
loadFacebookSdk();
|
||||
setTimeout(() => launchEmbeddedSignup(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = () => {
|
||||
loadFacebookSdk();
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fbSdkLoaded,
|
||||
isProcessing,
|
||||
processingMessage,
|
||||
authCodeReceived,
|
||||
authCode,
|
||||
businessData,
|
||||
isAuthenticating,
|
||||
|
||||
// Computed
|
||||
benefits,
|
||||
showLoader,
|
||||
|
||||
// Methods
|
||||
launchEmbeddedSignup,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export function useWhatsappReauthorization(inboxId) {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const authHeaders = computed(() => {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const showLoader = computed(
|
||||
() => isAuthenticating.value || isProcessing.value
|
||||
);
|
||||
|
||||
// Error handling
|
||||
const handleReauthorizationError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleReauthorizationCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
|
||||
};
|
||||
|
||||
const handleReauthorizationSuccess = async () => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
// Refresh the inbox to update reauthorization status
|
||||
await store.dispatch('inboxes/get');
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS'));
|
||||
|
||||
// Reload the page to reflect the changes
|
||||
router.go(0);
|
||||
};
|
||||
|
||||
// Reauthorization flow
|
||||
const completeReauthorizationFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleReauthorizationError({
|
||||
error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send reauthorization request
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/reauthorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
inbox_id: inboxId,
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
authCode.value = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
handleReauthorizationSuccess();
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleReauthorizationError({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
(businessDataLocal.business_id || businessDataLocal.businessId) &&
|
||||
(businessDataLocal.waba_id || businessDataLocal.wabaId)
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessDataLocal = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessDataLocal) {
|
||||
businessDataLocal = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id:
|
||||
businessDataLocal.business_id || businessDataLocal.businessId,
|
||||
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
|
||||
phone_number_id:
|
||||
businessDataLocal.phone_number_id ||
|
||||
businessDataLocal.phoneNumberId ||
|
||||
businessDataLocal.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
businessData.value = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeReauthorizationFlow(normalizedData);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleReauthorizationError({
|
||||
error: t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleReauthorizationCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleReauthorizationError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (businessData.value) {
|
||||
completeReauthorizationFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleReauthorizationError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Handle Facebook embedded signup message events
|
||||
try {
|
||||
const originUrl = new URL(event.origin);
|
||||
const allowedHosts = ['facebook.com', 'www.facebook.com'];
|
||||
if (!allowedHosts.includes(originUrl.hostname)) return;
|
||||
} catch (error) {
|
||||
// Invalid origin URL, reject the event
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
};
|
||||
|
||||
// Facebook SDK
|
||||
const loadFacebookSdk = () => {
|
||||
if (window.FB) {
|
||||
fbSdkLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const launchReauthorization = () => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
if (!window.FB) {
|
||||
loadFacebookSdk();
|
||||
setTimeout(() => launchReauthorization(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
// Using auth_type: 'reauthorize' to force reauthorization
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
auth_type: 'reauthorize',
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = () => {
|
||||
loadFacebookSdk();
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fbSdkLoaded,
|
||||
isProcessing,
|
||||
processingMessage,
|
||||
isAuthenticating,
|
||||
|
||||
// Computed
|
||||
showLoader,
|
||||
|
||||
// Methods
|
||||
launchReauthorization,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
};
|
||||
}
|
||||
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
name: 'Incoming',
|
||||
i18nKey: 'INCOMING',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
name: 'Outgoing',
|
||||
i18nKey: 'OUTGOING',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
i18nKey: 'NONE',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'Low',
|
||||
i18nKey: 'LOW',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
i18nKey: 'MEDIUM',
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
name: 'High',
|
||||
i18nKey: 'HIGH',
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
name: 'Urgent',
|
||||
i18nKey: 'URGENT',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
import filterQueryGenerator from './filterQueryGenerator';
|
||||
import actionQueryGenerator from './actionQueryGenerator';
|
||||
@@ -103,6 +101,7 @@ export const getActionOptions = ({
|
||||
slaPolicies,
|
||||
type,
|
||||
addNoneToListFn,
|
||||
priorityOptions,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
@@ -110,7 +109,7 @@ export const getActionOptions = ({
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
change_priority: PRIORITY_CONDITION_VALUES,
|
||||
change_priority: priorityOptions,
|
||||
add_sla: slaPolicies,
|
||||
};
|
||||
return actionsMap[type];
|
||||
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
|
||||
statusFilterOptions,
|
||||
teams,
|
||||
type,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
}) => {
|
||||
if (isCustomAttributeCheckbox(customAttributes, type)) {
|
||||
return booleanFilterOptions;
|
||||
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: MESSAGE_CONDITION_VALUES,
|
||||
priority: PRIORITY_CONDITION_VALUES,
|
||||
message_type: messageTypeOptions,
|
||||
priority: priorityOptions,
|
||||
};
|
||||
|
||||
return conditionFilterMaps[type];
|
||||
|
||||
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
|
||||
|
||||
import allCountries from 'shared/constants/countries.js';
|
||||
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
export const customAttributes = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -468,7 +473,7 @@ export const inboxes = [
|
||||
welcome_title: '',
|
||||
welcome_tagline: '',
|
||||
web_widget_script:
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
|
||||
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
|
||||
reply_time: 'in_a_few_minutes',
|
||||
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
|
||||
];
|
||||
export const languages = allLanguages;
|
||||
export const countries = allCountries;
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const automationToSubmit = {
|
||||
name: 'Fayaz',
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
"NUMBER": "Number",
|
||||
"LINK": "Link",
|
||||
"DATE": "Date",
|
||||
"LIST": "List",
|
||||
"CHECKBOX": "Checkbox"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add Custom Attribute",
|
||||
"SUBMIT": "Create",
|
||||
|
||||
@@ -150,6 +150,17 @@
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
"OUTGOING": "Outgoing Message"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "None",
|
||||
"LOW": "Low",
|
||||
"MEDIUM": "Medium",
|
||||
"HIGH": "High",
|
||||
"URGENT": "Urgent"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
"MESSAGE_CONTAINS": "Message Contains",
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"PLACEHOLDER": "Search",
|
||||
"EMPTY_STATE": "No results found"
|
||||
},
|
||||
"CLOSE": "Close",
|
||||
"FILE_SIZE": {
|
||||
"MB": "MB"
|
||||
}
|
||||
"CLOSE": "Close"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"MULTISELECT": {
|
||||
"ENTER_TO_SELECT": "Press enter to select",
|
||||
"ENTER_TO_REMOVE": "Press enter to remove",
|
||||
"NO_OPTIONS": "List is empty",
|
||||
"SELECT_ONE": "Select one",
|
||||
"SELECT": "Select"
|
||||
}
|
||||
|
||||
@@ -222,10 +222,17 @@
|
||||
"DESC": "Start supporting your customers via WhatsApp.",
|
||||
"PROVIDERS": {
|
||||
"LABEL": "API Provider",
|
||||
"WHATSAPP_EMBEDDED": "WhatsApp Business",
|
||||
"TWILIO": "Twilio",
|
||||
"WHATSAPP_CLOUD": "WhatsApp Cloud",
|
||||
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"360_DIALOG": "360Dialog"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
|
||||
},
|
||||
"INBOX_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
@@ -264,6 +271,28 @@
|
||||
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "No manual configuration required",
|
||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"SIGNUP_ERROR": "Signup error occurred",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
@@ -571,7 +600,24 @@
|
||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
"WHATSAPP_RECONFIGURE_TITLE": "Reconfigure WhatsApp",
|
||||
"WHATSAPP_RECONFIGURE_SUBHEADER": "Update your WhatsApp Business account configuration using the embedded signup flow.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure with WhatsApp",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to initiate WhatsApp reconfiguration. Please try again.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
|
||||
"WHATSAPP_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize WhatsApp Connection",
|
||||
"DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.",
|
||||
"AUTH_PROCESSING": "Processing WhatsApp reauthorization...",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Waiting for business information...",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"PROCESSING": "Updating your WhatsApp configuration...",
|
||||
"SUCCESS": "WhatsApp successfully reauthorized!",
|
||||
"ERROR": "Failed to reauthorize WhatsApp. Please try again.",
|
||||
"CANCELLED": "WhatsApp reauthorization was cancelled.",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received. Please try again."
|
||||
}
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Help Center",
|
||||
@@ -591,6 +637,12 @@
|
||||
"MESSAGE_SUCCESS": "Reconnection successful",
|
||||
"MESSAGE_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"WHATSAPP_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize WhatsApp",
|
||||
"SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services",
|
||||
"MESSAGE_SUCCESS": "WhatsApp reconnection successful",
|
||||
"MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again"
|
||||
},
|
||||
"PRE_CHAT_FORM": {
|
||||
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
|
||||
"SET_FIELDS": "Pre chat form fields",
|
||||
|
||||
@@ -501,26 +501,9 @@
|
||||
},
|
||||
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
|
||||
"CREATE": {
|
||||
"TITLE": "Create New Document",
|
||||
"DESCRIPTION": "Add a new document from a website URL or by uploading a PDF file",
|
||||
"TITLE": "Add a document",
|
||||
"SUCCESS_MESSAGE": "The document has been successfully created",
|
||||
"ERROR_MESSAGE": "There was an error creating the document, please try again.",
|
||||
"FORM": {
|
||||
"SOURCE_TYPE": {
|
||||
"LABEL": "Choose Source Type",
|
||||
"WEBSITE_URL": "Website URL",
|
||||
"PDF_UPLOAD": "PDF Upload"
|
||||
},
|
||||
"PDF_UPLOAD": {
|
||||
"LABEL": "Upload PDF Document"
|
||||
},
|
||||
"ASSISTANT": {
|
||||
"LABEL": "Select Assistant"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"MAX_SIZE": "Maximum file size: {size}MB"
|
||||
}
|
||||
}
|
||||
"ERROR_MESSAGE": "There was an error creating the document, please try again."
|
||||
},
|
||||
"FORM": {
|
||||
"URL": {
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "None",
|
||||
"LOW": "Low",
|
||||
"MEDIUM": "Medium",
|
||||
"HIGH": "High",
|
||||
"URGENT": "Urgent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,6 @@ const handleCreateDialogClose = () => {
|
||||
showCreateDialog.value = false;
|
||||
};
|
||||
|
||||
const handleCreateDialogSuccess = () => {
|
||||
// Document is already added to the store optimistically, no need to refetch
|
||||
// The store action 'uploadPdf' or 'create' already adds the document via commit(mutationTypes.ADD)
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedDocument.value = documents.value.find(
|
||||
captainDocument => id === captainDocument.id
|
||||
@@ -176,7 +171,6 @@ onMounted(() => {
|
||||
v-if="showCreateDialog"
|
||||
ref="createDocumentDialog"
|
||||
@close="handleCreateDialogClose"
|
||||
@success="handleCreateDialogSuccess"
|
||||
/>
|
||||
<DeleteDialog
|
||||
v-if="selectedDocument"
|
||||
|
||||
@@ -40,8 +40,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
@@ -53,6 +51,18 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'getUIFlags',
|
||||
}),
|
||||
models() {
|
||||
return ATTRIBUTE_MODELS.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_MODELS.${item.key}`),
|
||||
}));
|
||||
},
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
|
||||
@@ -113,7 +113,11 @@ const tableHeaders = computed(() => {
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_type }}
|
||||
{{
|
||||
$t(
|
||||
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
|
||||
@@ -32,7 +32,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
@@ -59,6 +58,12 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
@@ -84,9 +89,9 @@ export default {
|
||||
selectedAttributeType() {
|
||||
return this.types.find(
|
||||
item =>
|
||||
item.option.toLowerCase() ===
|
||||
item.key.toLowerCase() ===
|
||||
this.selectedAttribute.attribute_display_type
|
||||
).id;
|
||||
)?.id;
|
||||
},
|
||||
keyErrorMessage() {
|
||||
if (!this.v$.attributeKey.isKey) {
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
export const ATTRIBUTE_MODELS = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Conversation',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Contact',
|
||||
},
|
||||
{ id: 0, key: 'CONVERSATION' },
|
||||
{ id: 1, key: 'CONTACT' },
|
||||
];
|
||||
|
||||
export const ATTRIBUTE_TYPES = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Text',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Number',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
option: 'Link',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
option: 'Date',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
option: 'List',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
option: 'Checkbox',
|
||||
},
|
||||
{ id: 0, key: 'TEXT' },
|
||||
{ id: 1, key: 'NUMBER' },
|
||||
{ id: 4, key: 'LINK' },
|
||||
{ id: 5, key: 'DATE' },
|
||||
{ id: 6, key: 'LIST' },
|
||||
{ id: 7, key: 'CHECKBOX' },
|
||||
];
|
||||
|
||||
@@ -47,6 +47,13 @@ export default {
|
||||
this.currentInbox.provider === 'whatsapp_cloud'
|
||||
);
|
||||
},
|
||||
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
|
||||
shouldShowWhatsAppWebhookDetails() {
|
||||
return (
|
||||
this.isWhatsAppCloudInbox &&
|
||||
this.currentInbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
},
|
||||
message() {
|
||||
if (this.isATwilioInbox) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
@@ -66,7 +73,7 @@ export default {
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (this.isWhatsAppCloudInbox) {
|
||||
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
|
||||
)}`;
|
||||
@@ -113,8 +120,11 @@ export default {
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<p class="mt-8 font-medium text-n-slate-11">
|
||||
<div
|
||||
v-if="shouldShowWhatsAppWebhookDetails"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
|
||||
</p>
|
||||
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
||||
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
||||
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
|
||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
@@ -45,6 +46,7 @@ export default {
|
||||
NextButton,
|
||||
InstagramReauthorize,
|
||||
DuplicateInboxBanner,
|
||||
WhatsappReauthorize,
|
||||
Editor,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
@@ -231,6 +233,11 @@ export default {
|
||||
facebookUnauthorized() {
|
||||
return this.isAFacebookInbox && this.inbox.reauthorization_required;
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
googleUnauthorized() {
|
||||
const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes(
|
||||
this.inbox.imap_address
|
||||
@@ -410,6 +417,7 @@ export default {
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
||||
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
|
||||
@@ -1,48 +1,140 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
|
||||
import twilioIcon from 'dashboard/assets/images/twilio.png';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP_CLOUD: 'whatsapp_cloud',
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProvider = computed(() => route.query.provider);
|
||||
|
||||
const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
value: PROVIDER_TYPES.WHATSAPP,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: whatsappIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: 'whatsapp_cloud',
|
||||
};
|
||||
{
|
||||
value: PROVIDER_TYPES.TWILIO,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: twilioIcon,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: { provider: providerValue },
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
||||
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
|
||||
/>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
|
||||
<select v-model="provider">
|
||||
<option value="whatsapp_cloud">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
|
||||
</option>
|
||||
<option value="twilio">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Provider Selection View -->
|
||||
<div v-if="showProviderSelection">
|
||||
<div class="mb-10 text-left">
|
||||
<h1 class="mb-2 text-lg font-medium text-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
|
||||
</h1>
|
||||
<p class="text-sm leading-relaxed text-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Provider Cards -->
|
||||
<div class="flex justify-start gap-6">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.value"
|
||||
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
|
||||
@click="selectProvider(provider.value)"
|
||||
>
|
||||
<!-- Provider Icon -->
|
||||
<div class="flex justify-start mb-5">
|
||||
<div
|
||||
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="provider.icon"
|
||||
:alt="provider.label"
|
||||
class="object-contain size-[26px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="text-start">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
|
||||
{{ provider.label }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-11">
|
||||
{{ provider.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
|
||||
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
|
||||
<CloudWhatsapp v-else />
|
||||
<!-- Configuration View -->
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
|
||||
<!-- Provider Configuration Forms -->
|
||||
<WhatsappEmbeddedSignup
|
||||
v-if="shouldShowEmbeddedSignup(selectedProvider)"
|
||||
/>
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
type="whatsapp"
|
||||
/>
|
||||
<ThreeSixtyDialogWhatsapp
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
|
||||
/>
|
||||
<CloudWhatsapp v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
|
||||
const {
|
||||
fbSdkLoaded,
|
||||
processingMessage,
|
||||
isAuthenticating,
|
||||
benefits,
|
||||
showLoader,
|
||||
launchEmbeddedSignup,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
} = useWhatsappEmbeddedSignup();
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<LoadingState v-if="showLoader" :message="processingMessage" />
|
||||
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-start mb-6 text-start">
|
||||
<div class="flex justify-start mb-6">
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="whatsappIcon"
|
||||
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
|
||||
class="object-contain w-8 h-8"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm leading-[24px] text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<div
|
||||
v-for="benefit in benefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 text-sm text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
|
||||
{{ benefit.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="!fbSdkLoaded || isAuthenticating"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
class="w-full"
|
||||
@click="launchEmbeddedSignup"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
|
||||
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const {
|
||||
processingMessage,
|
||||
showLoader,
|
||||
launchReauthorization,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
} = useWhatsappReauthorization(props.inbox.id);
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="launchReauthorization"
|
||||
/>
|
||||
<LoadingState v-if="showLoader" :message="processingMessage" />
|
||||
</div>
|
||||
</template>
|
||||
+41
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -34,6 +35,20 @@ export default {
|
||||
validations: {
|
||||
whatsAppInboxAPIKey: { required },
|
||||
},
|
||||
computed: {
|
||||
isWhatsAppCloudChannel() {
|
||||
return this.inbox.provider === 'whatsapp_cloud';
|
||||
},
|
||||
hasWhatsappAppId() {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
},
|
||||
shouldShowReconfigureButton() {
|
||||
return this.isWhatsAppCloudChannel && this.hasWhatsappAppId;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
@@ -83,6 +98,18 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
reconfigureWhatsApp() {
|
||||
// Initialize the WhatsApp reauthorization flow
|
||||
const { launchReauthorization, initialize } = useWhatsappReauthorization(
|
||||
this.inbox.id
|
||||
);
|
||||
|
||||
// Initialize and launch
|
||||
initialize();
|
||||
launchReauthorization();
|
||||
|
||||
// Note: cleanup will happen when component unmounts or user navigates away
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -208,7 +235,21 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
<!-- Show reconfigure button for WhatsApp Cloud with embedded signup and app ID configured -->
|
||||
<SettingsSection
|
||||
v-if="shouldShowReconfigureButton"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<NextButton class="mt-2" @click="reconfigureWhatsApp">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsSection>
|
||||
<!-- Show API key update for non-embedded signup WhatsApp or when app ID is missing -->
|
||||
<SettingsSection
|
||||
v-else
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const actions = mutationTypes => ({
|
||||
async uploadPdf({ commit }, formData) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await CaptainDocumentAPI.uploadPdf(formData);
|
||||
const { data } = response;
|
||||
// Use UPSERT like the create action for consistency
|
||||
commit(mutationTypes.UPSERT, data);
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
return Promise.resolve(data);
|
||||
} catch (error) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
actions,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,13 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
|
||||
resetTriggered: false,
|
||||
darkMode: getDarkMode(chatwootSettings.darkMode),
|
||||
welcomeTitle: chatwootSettings.welcomeTitle || '',
|
||||
welcomeDescription: chatwootSettings.welcomeDescription || '',
|
||||
availableMessage: chatwootSettings.availableMessage || '',
|
||||
unavailableMessage: chatwootSettings.unavailableMessage || '',
|
||||
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
|
||||
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
|
||||
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
|
||||
|
||||
toggle(state) {
|
||||
IFrameHelper.events.toggleBubble(state);
|
||||
|
||||
@@ -166,6 +166,13 @@ export const IFrameHelper = {
|
||||
darkMode: window.$chatwoot.darkMode,
|
||||
showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog,
|
||||
campaignsSnoozedTill,
|
||||
welcomeTitle: window.$chatwoot.welcomeTitle,
|
||||
welcomeDescription: window.$chatwoot.welcomeDescription,
|
||||
availableMessage: window.$chatwoot.availableMessage,
|
||||
unavailableMessage: window.$chatwoot.unavailableMessage,
|
||||
enableFileUpload: window.$chatwoot.enableFileUpload,
|
||||
enableEmojiPicker: window.$chatwoot.enableEmojiPicker,
|
||||
enableEndConversation: window.$chatwoot.enableEndConversation,
|
||||
});
|
||||
IFrameHelper.onLoad({
|
||||
widgetColor: message.config.channelConfig.widgetColor,
|
||||
|
||||
@@ -69,7 +69,7 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
modelValue() {
|
||||
this.resizeTextarea();
|
||||
// 🚨 watch triggers every time the value is changed, we cannot set this to focus then
|
||||
// when this runs, it sets the cursor to the end of the body, ignoring the signature
|
||||
|
||||
@@ -24,7 +24,10 @@ export default {
|
||||
return { isUploading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
}),
|
||||
fileUploadSizeLimit() {
|
||||
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
},
|
||||
@@ -40,6 +43,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
handleClipboardPaste(e) {
|
||||
// If file picker is not enabled, do not allow paste
|
||||
if (!this.shouldShowFilePicker) return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
// items is a DataTransferItemList object which does not have forEach method
|
||||
const itemsArray = Array.from(items);
|
||||
|
||||
@@ -47,11 +47,11 @@ const containerClasses = computed(() => [
|
||||
</div>
|
||||
<h2
|
||||
v-dompurify-html="introHeading"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
|
||||
/>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(introBody)"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -41,9 +41,15 @@ export default {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
|
||||
}),
|
||||
showAttachment() {
|
||||
return this.hasAttachmentsEnabled && this.userInput.length === 0;
|
||||
return (
|
||||
this.shouldShowFilePicker &&
|
||||
this.hasAttachmentsEnabled &&
|
||||
this.userInput.length === 0
|
||||
);
|
||||
},
|
||||
showSendButton() {
|
||||
return this.userInput.length > 0;
|
||||
@@ -143,7 +149,7 @@ export default {
|
||||
:on-attach="onSendAttachment"
|
||||
/>
|
||||
<button
|
||||
v-if="hasEmojiPickerEnabled"
|
||||
v-if="shouldShowEmojiPicker && hasEmojiPickerEnabled"
|
||||
class="flex items-center justify-center min-h-8 min-w-8"
|
||||
:aria-label="$t('EMOJI.ARIA_LABEL')"
|
||||
@click="toggleEmojiPicker"
|
||||
@@ -158,7 +164,7 @@ export default {
|
||||
/>
|
||||
</button>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-if="shouldShowEmojiPicker && showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
canUserEndConversation: 'appConfig/getCanUserEndConversation',
|
||||
}),
|
||||
canLeaveConversation() {
|
||||
return [
|
||||
@@ -82,6 +83,7 @@ export default {
|
||||
<button
|
||||
v-if="
|
||||
canLeaveConversation &&
|
||||
canUserEndConversation &&
|
||||
hasEndConversationEnabled &&
|
||||
showEndConversationButton
|
||||
"
|
||||
|
||||
@@ -29,6 +29,8 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
availableMessage: 'appConfig/getAvailableMessage',
|
||||
unavailableMessage: 'appConfig/getUnavailableMessage',
|
||||
}),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
@@ -40,6 +42,11 @@ export default {
|
||||
id: agent.id,
|
||||
}));
|
||||
},
|
||||
headerMessage() {
|
||||
return this.isOnline
|
||||
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
|
||||
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
@@ -71,12 +78,8 @@ export default {
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-n-slate-12">
|
||||
{{
|
||||
isOnline
|
||||
? $t('TEAM_AVAILABILITY.ONLINE')
|
||||
: $t('TEAM_AVAILABILITY.OFFLINE')
|
||||
}}
|
||||
<div class="font-medium text-n-slate-12 line-clamp-2">
|
||||
{{ headerMessage }}
|
||||
</div>
|
||||
<div class="text-n-slate-11">
|
||||
{{ replyWaitMessage }}
|
||||
|
||||
@@ -119,8 +119,10 @@ export default {
|
||||
>
|
||||
<ChatHeaderExpanded
|
||||
v-if="!isHeaderCollapsed"
|
||||
:intro-heading="channelConfig.welcomeTitle"
|
||||
:intro-body="channelConfig.welcomeTagline"
|
||||
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
|
||||
:intro-body="
|
||||
appConfig.welcomeDescription || channelConfig.welcomeTagline
|
||||
"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,13 @@ const state = {
|
||||
widgetStyle: 'standard',
|
||||
darkMode: 'light',
|
||||
isUpdatingRoute: false,
|
||||
welcomeTitle: '',
|
||||
welcomeDescription: '',
|
||||
availableMessage: '',
|
||||
unavailableMessage: '',
|
||||
enableFileUpload: true,
|
||||
enableEmojiPicker: true,
|
||||
enableEndConversation: true,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -34,6 +41,13 @@ export const getters = {
|
||||
darkMode: $state => $state.darkMode,
|
||||
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
|
||||
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
|
||||
getWelcomeHeading: $state => $state.welcomeTitle,
|
||||
getWelcomeTagline: $state => $state.welcomeDescription,
|
||||
getAvailableMessage: $state => $state.availableMessage,
|
||||
getUnavailableMessage: $state => $state.unavailableMessage,
|
||||
getShouldShowFilePicker: $state => $state.enableFileUpload,
|
||||
getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
|
||||
getCanUserEndConversation: $state => $state.enableEndConversation,
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -46,6 +60,13 @@ export const actions = {
|
||||
showUnreadMessagesDialog,
|
||||
widgetStyle = 'rounded',
|
||||
darkMode = 'light',
|
||||
welcomeTitle = '',
|
||||
welcomeDescription = '',
|
||||
availableMessage = '',
|
||||
unavailableMessage = '',
|
||||
enableFileUpload = true,
|
||||
enableEmojiPicker = true,
|
||||
enableEndConversation = true,
|
||||
}
|
||||
) {
|
||||
commit(SET_WIDGET_APP_CONFIG, {
|
||||
@@ -55,6 +76,13 @@ export const actions = {
|
||||
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
|
||||
widgetStyle,
|
||||
darkMode,
|
||||
welcomeTitle,
|
||||
welcomeDescription,
|
||||
availableMessage,
|
||||
unavailableMessage,
|
||||
enableFileUpload,
|
||||
enableEmojiPicker,
|
||||
enableEndConversation,
|
||||
});
|
||||
},
|
||||
toggleWidgetOpen({ commit }, isWidgetOpen) {
|
||||
@@ -90,6 +118,13 @@ export const mutations = {
|
||||
$state.darkMode = data.darkMode;
|
||||
$state.locale = data.locale || $state.locale;
|
||||
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
|
||||
$state.welcomeTitle = data.welcomeTitle;
|
||||
$state.welcomeDescription = data.welcomeDescription;
|
||||
$state.availableMessage = data.availableMessage;
|
||||
$state.unavailableMessage = data.unavailableMessage;
|
||||
$state.enableFileUpload = data.enableFileUpload;
|
||||
$state.enableEmojiPicker = data.enableEmojiPicker;
|
||||
$state.enableEndConversation = data.enableEndConversation;
|
||||
},
|
||||
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
|
||||
$state.isWidgetOpen = isWidgetOpen;
|
||||
|
||||
@@ -19,6 +19,48 @@ describe('#getters', () => {
|
||||
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getAvailableMessage', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { availableMessage: 'We reply quickly' };
|
||||
expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
|
||||
});
|
||||
});
|
||||
describe('#getWelcomeHeading', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { welcomeTitle: 'Hello!' };
|
||||
expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
|
||||
});
|
||||
});
|
||||
describe('#getWelcomeTagline', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { welcomeDescription: 'Welcome to our site' };
|
||||
expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
|
||||
});
|
||||
});
|
||||
describe('#getShouldShowFilePicker', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableFileUpload: true };
|
||||
expect(getters.getShouldShowFilePicker(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getShouldShowEmojiPicker', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableEmojiPicker: true };
|
||||
expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getCanUserEndConversation', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableEndConversation: true };
|
||||
expect(getters.getCanUserEndConversation(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getUnavailableMessage', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { unavailableMessage: 'We are offline' };
|
||||
expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
|
||||
});
|
||||
});
|
||||
describe('#getIsUpdatingRoute', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { isUpdatingRoute: true };
|
||||
|
||||
@@ -5,6 +5,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
|
||||
if channel_is_inactive?(channel)
|
||||
Rails.logger.info("Channel is inactive: #{channel.inspect}")
|
||||
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -66,7 +66,6 @@ class Channel::WebWidget < ApplicationRecord
|
||||
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src=BASE_URL+\"/packs/js/sdk.js\";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ class Inbox < ApplicationRecord
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Handles WhatsApp API communication
|
||||
module Whatsapp::ApiService
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def whatsapp_api_version
|
||||
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/oauth/access_token",
|
||||
{
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: @code
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token exchange failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
raise "No access token in response: #{data}" unless data['access_token']
|
||||
|
||||
data['access_token']
|
||||
end
|
||||
|
||||
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/phone_numbers",
|
||||
{ access_token: access_token }
|
||||
)
|
||||
|
||||
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
phone_numbers = data['data']
|
||||
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
|
||||
|
||||
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
|
||||
|
||||
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: "+#{display_phone_number}",
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, access_token)
|
||||
HTTParty.post(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
|
||||
{
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: '212834' }.to_json
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def override_waba_webhook(waba_id, channel, access_token)
|
||||
callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
verify_token = channel.provider_config['webhook_verify_token']
|
||||
|
||||
response = HTTParty.post(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
|
||||
{
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
},
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
}
|
||||
)
|
||||
|
||||
return if response.success?
|
||||
|
||||
Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
|
||||
raise "Webhook override failed: #{response.body}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
|
||||
def build_app_access_token
|
||||
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
|
||||
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
"#{app_id}|#{app_secret}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
include Rails.application.routes.url_helpers
|
||||
include Whatsapp::ApiService
|
||||
include Whatsapp::TokenValidator
|
||||
|
||||
def initialize(params)
|
||||
@account = params[:account]
|
||||
@code = params[:code]
|
||||
@business_id = params[:business_id]
|
||||
@waba_id = params[:waba_id]
|
||||
@phone_number_id = params[:phone_number_id]
|
||||
@inbox_id = params[:inbox_id]
|
||||
end
|
||||
|
||||
def perform
|
||||
# Validate required parameters
|
||||
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
|
||||
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
|
||||
end
|
||||
|
||||
GlobalConfig.clear_cache
|
||||
# Exchange code for user access token
|
||||
access_token = exchange_code_for_token
|
||||
|
||||
# Use the provided business info directly (more efficient)
|
||||
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
|
||||
|
||||
# Validate that the token has access to the provided WABA (security check)
|
||||
validate_token_waba_access(access_token, @waba_id)
|
||||
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
create_or_update_channel(waba_info, phone_info, access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
def perform_reauthorization
|
||||
validate_reauthorization_params!
|
||||
channel = find_and_validate_channel
|
||||
|
||||
GlobalConfig.clear_cache
|
||||
access_token = exchange_code_for_token
|
||||
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
|
||||
validate_token_waba_access(access_token, @waba_id)
|
||||
|
||||
update_channel_for_reauthorization(channel, phone_info, access_token)
|
||||
register_phone_number(phone_info[:phone_number_id], access_token)
|
||||
override_waba_webhook(@waba_id, channel, access_token)
|
||||
|
||||
channel
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Reauthorization failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_reauthorization_params!
|
||||
return if @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? && @inbox_id.present?
|
||||
|
||||
raise ArgumentError, 'Code, business_id, waba_id, phone_number_id, and inbox_id are all required for reauthorization'
|
||||
end
|
||||
|
||||
def find_and_validate_channel
|
||||
inbox = @account.inboxes.find_by(id: @inbox_id)
|
||||
raise ActiveRecord::RecordNotFound, 'Inbox not found' unless inbox
|
||||
raise ArgumentError, 'Inbox is not a WhatsApp channel' unless inbox.channel_type == 'Channel::Whatsapp'
|
||||
|
||||
channel = inbox.channel
|
||||
raise ArgumentError, 'Channel is not WhatsApp Cloud provider' unless channel.provider == 'whatsapp_cloud'
|
||||
|
||||
channel
|
||||
end
|
||||
|
||||
def create_or_update_channel(waba_info, phone_info, access_token)
|
||||
existing_channel = find_existing_channel(phone_info[:phone_number])
|
||||
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
|
||||
|
||||
if existing_channel
|
||||
Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
|
||||
raise "Channel already exists: #{existing_channel.phone_number}"
|
||||
else
|
||||
channel = create_new_channel(channel_attributes, phone_info)
|
||||
register_phone_number(phone_info[:phone_number_id], access_token)
|
||||
override_waba_webhook(waba_info[:waba_id], channel, access_token)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def find_existing_channel(phone_number)
|
||||
Channel::Whatsapp.find_by(account: @account, phone_number: phone_number)
|
||||
end
|
||||
|
||||
def build_channel_attributes(waba_info, phone_info, access_token)
|
||||
{
|
||||
phone_number: phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
api_key: access_token,
|
||||
phone_number_id: phone_info[:phone_number_id],
|
||||
business_account_id: waba_info[:waba_id],
|
||||
source: 'embedded_signup'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def create_new_channel(attributes, phone_info)
|
||||
channel = Channel::Whatsapp.create!(account: @account, **attributes)
|
||||
create_inbox_for_channel(channel, phone_info)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
|
||||
def create_inbox_for_channel(channel, phone_info)
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: "#{phone_info[:business_name]} WhatsApp",
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def update_channel_for_reauthorization(channel, phone_info, access_token)
|
||||
# Update channel with new access token and configuration
|
||||
channel.update!(
|
||||
provider_config: channel.provider_config.merge(
|
||||
'api_key' => access_token,
|
||||
'phone_number_id' => phone_info[:phone_number_id],
|
||||
'business_account_id' => @waba_id,
|
||||
'reauthorized_at' => Time.current.iso8601
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# Handles WhatsApp token validation
|
||||
module Whatsapp::TokenValidator
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def validate_token_waba_access(access_token, waba_id)
|
||||
token_debug_data = fetch_token_debug_data(access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope, waba_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_token_debug_data(access_token)
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
|
||||
{
|
||||
input_token: access_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token validation failed: #{response.body}" unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope, waba_id)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
|
||||
if resource.whatsapp?
|
||||
json.message_templates resource.channel.try(:message_templates)
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
end
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
||||
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
||||
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
||||
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
|
||||
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
|
||||
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
|
||||
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
|
||||
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
|
||||
<% if @global_config['IS_ENTERPRISE'] %>
|
||||
|
||||
@@ -29,7 +29,6 @@ window.chatwootSettings = {
|
||||
var BASE_URL = '';
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src= BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -30,7 +30,6 @@ window.chatwootSettings = {
|
||||
var BASE_URL = '';
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src= BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
@@ -126,6 +126,26 @@
|
||||
type: boolean
|
||||
# ------- End of Facebook Channel Related Config ------- #
|
||||
|
||||
# ------- WhatsApp Channel Related Config ------- #
|
||||
- name: WHATSAPP_APP_ID
|
||||
display_title: 'WhatsApp App ID'
|
||||
description: 'The Facebook App ID for WhatsApp Business API integration'
|
||||
locked: false
|
||||
- name: WHATSAPP_CONFIGURATION_ID
|
||||
display_title: 'WhatsApp Configuration ID'
|
||||
description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
|
||||
locked: false
|
||||
- name: WHATSAPP_APP_SECRET
|
||||
display_title: 'WhatsApp App Secret'
|
||||
description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
|
||||
locked: false
|
||||
- name: WHATSAPP_API_VERSION
|
||||
display_title: 'WhatsApp API Version'
|
||||
description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
|
||||
value: 'v22.0'
|
||||
locked: false
|
||||
# ------- End of WhatsApp Channel Related Config ------- #
|
||||
|
||||
# MARK: Microsoft Email Channel Config
|
||||
- name: AZURE_APP_ID
|
||||
display_title: 'Azure App ID'
|
||||
|
||||
+8
-5
@@ -63,11 +63,7 @@ Rails.application.routes.draw do
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy] do
|
||||
collection do
|
||||
post :upload_pdf
|
||||
end
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
@@ -486,6 +482,13 @@ Rails.application.routes.draw do
|
||||
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
|
||||
post 'webhooks/instagram', to: 'webhooks/instagram#events'
|
||||
|
||||
namespace :whatsapp do
|
||||
get 'signup', to: 'embedded#new'
|
||||
get 'signup/callback', to: 'embedded#callback'
|
||||
post 'embedded_signup', to: 'embedded#embedded_signup'
|
||||
post 'reauthorize', to: 'embedded#reauthorize'
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
class AddSourceMetadataToCaptainDocuments < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_documents, :source_type, :string, default: 'url'
|
||||
add_column :captain_documents, :content_type, :string
|
||||
add_column :captain_documents, :file_size, :integer
|
||||
add_column :captain_documents, :processed_at, :datetime
|
||||
|
||||
add_index :captain_documents, :source_type
|
||||
add_index :captain_documents, :content_type
|
||||
end
|
||||
end
|
||||
+1
-9
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -289,17 +289,9 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.integer "document_type", default: 0, null: false
|
||||
t.string "source_type", default: "url"
|
||||
t.string "content_type"
|
||||
t.integer "file_size"
|
||||
t.datetime "processed_at"
|
||||
t.index ["account_id"], name: "index_captain_documents_on_account_id"
|
||||
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
|
||||
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
|
||||
t.index ["content_type"], name: "index_captain_documents_on_content_type"
|
||||
t.index ["document_type"], name: "index_captain_documents_on_document_type"
|
||||
t.index ["source_type"], name: "index_captain_documents_on_source_type"
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,13 +5,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_documents, except: [:create]
|
||||
before_action :set_document, only: [:show, :destroy]
|
||||
before_action :set_assistant, only: [:create, :upload_pdf]
|
||||
|
||||
before_action :set_assistant, only: [:create]
|
||||
RESULTS_PER_PAGE = 25
|
||||
# Fixed PDF size limit
|
||||
MAX_PDF_SIZE = 25.megabytes
|
||||
ALLOWED_PDF_CONTENT_TYPES = ['application/pdf'].freeze
|
||||
PDF_MAGIC_NUMBERS = ['%PDF'].freeze
|
||||
|
||||
def index
|
||||
base_query = @documents
|
||||
@@ -32,19 +27,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def upload_pdf
|
||||
validation_errors = validate_upload_prerequisites
|
||||
return render_could_not_create_error(validation_errors) if validation_errors
|
||||
|
||||
process_pdf_upload
|
||||
rescue Captain::Document::LimitExceededError => e
|
||||
handle_limit_exceeded_error(e)
|
||||
rescue ActiveStorage::FileNotFoundError => e
|
||||
handle_file_not_found_error(e)
|
||||
rescue StandardError => e
|
||||
handle_general_upload_error(e)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@document.destroy
|
||||
head :no_content
|
||||
@@ -61,8 +43,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
assistant_id = action_name == 'upload_pdf' ? pdf_params[:assistant_id] : document_params[:assistant_id]
|
||||
@assistant = Current.account.captain_assistants.find_by(id: assistant_id)
|
||||
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@@ -76,130 +57,4 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
def document_params
|
||||
params.require(:document).permit(:name, :external_link, :assistant_id)
|
||||
end
|
||||
|
||||
def pdf_params
|
||||
params.permit(:pdf_document, :assistant_id)
|
||||
end
|
||||
|
||||
def pdf_file_present?
|
||||
pdf_params[:pdf_document].present?
|
||||
end
|
||||
|
||||
def validate_pdf_file
|
||||
file = pdf_params[:pdf_document]
|
||||
|
||||
file_object_error = validate_file_object(file)
|
||||
return file_object_error if file_object_error
|
||||
|
||||
file_type_error = validate_file_type(file)
|
||||
return file_type_error if file_type_error
|
||||
|
||||
file_size_error = validate_file_size(file)
|
||||
return file_size_error if file_size_error
|
||||
|
||||
{ valid: true }
|
||||
end
|
||||
|
||||
def validate_file_object(file)
|
||||
return nil if file.respond_to?(:content_type) && file.respond_to?(:size)
|
||||
|
||||
{ valid: false, error: 'Invalid file object' }
|
||||
end
|
||||
|
||||
def validate_file_type(file)
|
||||
return nil if ALLOWED_PDF_CONTENT_TYPES.include?(file.content_type)
|
||||
|
||||
{ valid: false, error: 'Invalid file type. Only PDF files are allowed.' }
|
||||
end
|
||||
|
||||
def validate_file_size(file)
|
||||
return nil if file.size <= MAX_PDF_SIZE
|
||||
|
||||
{ valid: false, error: "File size too large. Maximum size is #{MAX_PDF_SIZE / 1.megabyte}MB." }
|
||||
end
|
||||
|
||||
def create_pdf_blob
|
||||
file = pdf_params[:pdf_document]
|
||||
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: file.tempfile,
|
||||
filename: sanitize_filename(file.original_filename),
|
||||
content_type: file.content_type,
|
||||
metadata: {
|
||||
uploaded_by: Current.user&.id,
|
||||
assistant_id: @assistant.id,
|
||||
account_id: Current.account.id,
|
||||
original_filename: file.original_filename
|
||||
}
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to create PDF blob: #{e.message}"
|
||||
raise ActiveStorage::FileNotFoundError, 'Failed to upload PDF file'
|
||||
end
|
||||
|
||||
def generate_pdf_url(blob)
|
||||
Rails.application.routes.url_helpers.rails_blob_url(
|
||||
blob,
|
||||
host: ENV.fetch('FRONTEND_URL') { Rails.application.config.action_mailer.default_url_options[:host] }
|
||||
)
|
||||
end
|
||||
|
||||
def sanitize_filename(filename)
|
||||
return 'document' if filename.blank?
|
||||
|
||||
base_name = File.basename(filename, '.pdf')
|
||||
sanitized = base_name.gsub(/[^\w\s.-]/, '').strip.squeeze(' ')
|
||||
(sanitized.presence || 'document')
|
||||
end
|
||||
|
||||
def process_pdf_upload
|
||||
ActiveRecord::Base.transaction do
|
||||
blob = create_pdf_blob
|
||||
pdf_url = generate_pdf_url(blob)
|
||||
|
||||
@document = @assistant.documents.build(
|
||||
name: sanitize_filename(pdf_params[:pdf_document].original_filename),
|
||||
external_link: pdf_url,
|
||||
source_type: 'pdf_upload',
|
||||
content_type: pdf_params[:pdf_document].content_type,
|
||||
file_size: pdf_params[:pdf_document].size
|
||||
)
|
||||
@document.save!
|
||||
|
||||
log_pdf_upload_success
|
||||
|
||||
# Use the same response structure as create action for consistency
|
||||
render :create
|
||||
end
|
||||
end
|
||||
|
||||
def log_pdf_upload_success
|
||||
Rails.logger.info "PDF uploaded successfully - Document ID: #{@document.id}, Assistant ID: #{@assistant.id}, Account ID: #{Current.account.id}"
|
||||
end
|
||||
|
||||
def validate_upload_prerequisites
|
||||
return 'Missing Assistant' if @assistant.nil?
|
||||
return 'No PDF file provided' unless pdf_file_present?
|
||||
|
||||
validation_result = validate_pdf_file
|
||||
return validation_result[:error] unless validation_result[:valid]
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def handle_limit_exceeded_error(error)
|
||||
Rails.logger.warn "Document limit exceeded for assistant #{@assistant.id}: #{error.message}"
|
||||
render_could_not_create_error(error.message)
|
||||
end
|
||||
|
||||
def handle_file_not_found_error(error)
|
||||
Rails.logger.error "PDF file not found during upload: #{error.message}"
|
||||
render_could_not_create_error('PDF file could not be processed. Please try again.')
|
||||
end
|
||||
|
||||
def handle_general_upload_error(error)
|
||||
Rails.logger.error "PDF upload failed for assistant #{@assistant&.id}: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
render_could_not_create_error('PDF upload failed. Please try again.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +103,12 @@ slack:
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3
|
||||
|
||||
def perform(conversation, assistant)
|
||||
@conversation = conversation
|
||||
@@ -14,7 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
generate_and_process_response
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
|
||||
|
||||
handle_error(e)
|
||||
ensure
|
||||
@@ -27,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?
|
||||
@@ -43,19 +43,39 @@ 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)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
return 'system' if message.content.blank?
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
message.message_type == 'incoming' ? 'user' : 'system'
|
||||
end
|
||||
|
||||
def handoff_requested?
|
||||
|
||||
@@ -2,63 +2,17 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
if pdf_document?(document)
|
||||
perform_pdf_extraction(document)
|
||||
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
perform_firecrawl_crawl(document)
|
||||
else
|
||||
perform_simple_crawl(document)
|
||||
end
|
||||
end
|
||||
|
||||
def pdf_document?(document)
|
||||
return false if document.nil?
|
||||
|
||||
pdf_by_metadata?(document) || pdf_by_url?(document)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
include Captain::FirecrawlHelper
|
||||
|
||||
def pdf_by_metadata?(document)
|
||||
pdf_by_source_type?(document) || pdf_by_content_type?(document)
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
|
||||
def pdf_by_source_type?(document)
|
||||
document.respond_to?(:source_type) && document.source_type == 'pdf_upload'
|
||||
end
|
||||
|
||||
def pdf_by_content_type?(document)
|
||||
document.respond_to?(:content_type) && document.content_type&.include?('application/pdf')
|
||||
end
|
||||
|
||||
def pdf_by_url?(document)
|
||||
return false if document.external_link.blank?
|
||||
|
||||
url = document.external_link.downcase
|
||||
url.end_with?('.pdf') ||
|
||||
url.include?('/rails/active_storage/blobs/') ||
|
||||
(url.include?('blob') && url.include?('pdf'))
|
||||
end
|
||||
|
||||
def perform_pdf_extraction(document)
|
||||
document.update(status: 'in_progress')
|
||||
|
||||
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(document.external_link)
|
||||
result = pdf_extraction_service.perform
|
||||
|
||||
if result[:success] && result[:content].present?
|
||||
process_pdf_content_chunks(document, result[:content])
|
||||
else
|
||||
handle_pdf_extraction_failure(document, result)
|
||||
end
|
||||
rescue StandardError => e
|
||||
handle_pdf_extraction_error(document, e)
|
||||
end
|
||||
|
||||
def perform_simple_crawl(document)
|
||||
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
|
||||
|
||||
@@ -94,42 +48,4 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
|
||||
"#{webhook_url}?assistant_id=#{document.assistant_id}&token=#{generate_firecrawl_token(document.assistant_id, document.account_id)}"
|
||||
end
|
||||
|
||||
def process_pdf_content_chunks(document, content_chunks)
|
||||
Rails.logger.info "PDF extraction successful for document #{document.id}: #{content_chunks.length} chunks will be processed"
|
||||
|
||||
content_chunks.each_with_index do |content_chunk, index|
|
||||
log_chunk_queueing(document, content_chunk, index, content_chunks.length)
|
||||
queue_pdf_chunk_job(document, content_chunk)
|
||||
end
|
||||
|
||||
document.update(status: 'in_progress', processed_at: nil)
|
||||
Rails.logger.info "All #{content_chunks.length} chunks queued for processing for document #{document.id}"
|
||||
end
|
||||
|
||||
def log_chunk_queueing(document, content_chunk, index, total_chunks)
|
||||
page_num = content_chunk[:page_number]
|
||||
content_length = content_chunk[:content].length
|
||||
Rails.logger.info "Queueing chunk #{index + 1}/#{total_chunks} for document #{document.id} " \
|
||||
"(Page #{page_num}, #{content_length} chars)"
|
||||
end
|
||||
|
||||
def queue_pdf_chunk_job(document, content_chunk)
|
||||
Captain::Tools::PdfExtractionParserJob.perform_later(
|
||||
assistant_id: document.assistant_id,
|
||||
pdf_content: content_chunk,
|
||||
document_id: document.id
|
||||
)
|
||||
end
|
||||
|
||||
def handle_pdf_extraction_failure(document, result)
|
||||
error_message = result[:errors]&.join(', ') || 'Failed to extract text from PDF'
|
||||
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error_message}"
|
||||
document.update(status: 'available')
|
||||
end
|
||||
|
||||
def handle_pdf_extraction_error(document, error)
|
||||
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error.message}"
|
||||
document.update(status: 'available')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document, full_content = nil)
|
||||
# Use full content for FAQ generation if provided (for PDFs), otherwise use document.content
|
||||
content_for_faqs = full_content || document.content
|
||||
|
||||
# Skip processing if no content available
|
||||
return if content_for_faqs.blank?
|
||||
|
||||
def perform(document)
|
||||
reset_previous_responses(document)
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(content_for_faqs).generate
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
|
||||
faqs.each do |faq|
|
||||
create_response(faq, document)
|
||||
end
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
require 'securerandom'
|
||||
|
||||
class Captain::Tools::PdfExtractionParserJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
def perform(assistant_id:, pdf_content:, document_id: nil)
|
||||
validate_inputs!(assistant_id, pdf_content, document_id)
|
||||
|
||||
assistant = load_assistant(assistant_id)
|
||||
content_data = extract_content_data(pdf_content)
|
||||
|
||||
process_document(assistant, content_data, document_id)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
handle_record_not_found_error(e, document_id)
|
||||
rescue StandardError => e
|
||||
handle_processing_error(e, assistant_id, document_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_inputs!(assistant_id, pdf_content, _document_id)
|
||||
raise ArgumentError, 'Assistant ID is required' if assistant_id.blank?
|
||||
raise ArgumentError, 'PDF content is required' if pdf_content.blank?
|
||||
end
|
||||
|
||||
def extract_content_data(pdf_content)
|
||||
{
|
||||
content: pdf_content[:content] || '',
|
||||
page_number: pdf_content[:page_number] || 1,
|
||||
chunk_index: pdf_content[:chunk_index] || 1,
|
||||
total_chunks: pdf_content[:total_chunks] || 1
|
||||
}
|
||||
end
|
||||
|
||||
def load_assistant(assistant_id)
|
||||
Captain::Assistant.find(assistant_id)
|
||||
end
|
||||
|
||||
def processing_should_skip?(assistant)
|
||||
exceeded = limit_exceeded?(assistant.account)
|
||||
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}" if exceeded
|
||||
exceeded
|
||||
end
|
||||
|
||||
def load_document(document_id)
|
||||
return nil if document_id.blank?
|
||||
|
||||
Captain::Document.find(document_id)
|
||||
end
|
||||
|
||||
def log_chunk_processing(document_id, content_data)
|
||||
Rails.logger.info "Processing PDF chunk for document #{document_id}: " \
|
||||
"page #{content_data[:page_number]}, " \
|
||||
"chunk #{content_data[:chunk_index]}/#{content_data[:total_chunks]}"
|
||||
end
|
||||
|
||||
def update_document_content(document, content_data)
|
||||
document.update!(
|
||||
content: content_data[:content],
|
||||
status: 'available',
|
||||
processed_at: Time.current
|
||||
)
|
||||
|
||||
# Trigger FAQ generation for this chunk
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(document)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to parse PDF content for document #{document.id}: #{e.message}"
|
||||
raise "Failed to parse PDF data: #{e.message}"
|
||||
end
|
||||
|
||||
def create_new_document(assistant, content_data, document_id = nil)
|
||||
document_params = build_document_params(assistant, content_data, document_id)
|
||||
|
||||
new_document = Captain::Document.create!(document_params)
|
||||
|
||||
# Trigger FAQ generation for this chunk
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(new_document)
|
||||
|
||||
new_document
|
||||
rescue Captain::Document::LimitExceededError
|
||||
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}"
|
||||
return false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to parse PDF content for assistant #{assistant.id}: #{e.message}"
|
||||
raise "Failed to parse PDF data: #{e.message}"
|
||||
end
|
||||
|
||||
def build_document_params(assistant, content_data, document_id)
|
||||
title = generate_content_title(
|
||||
content_data[:content],
|
||||
content_data[:page_number],
|
||||
content_data[:chunk_index],
|
||||
content_data[:total_chunks]
|
||||
)
|
||||
|
||||
external_link = generate_external_link(document_id, content_data)
|
||||
|
||||
{
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
content: content_data[:content],
|
||||
name: title,
|
||||
external_link: external_link,
|
||||
status: 'available',
|
||||
source_type: 'pdf_upload',
|
||||
content_type: 'application/pdf'
|
||||
}
|
||||
end
|
||||
|
||||
def generate_external_link(document_id, content_data)
|
||||
page_number = content_data[:page_number]
|
||||
chunk_index = content_data[:chunk_index]
|
||||
|
||||
if document_id && page_number && chunk_index
|
||||
"pdf_chunk_#{document_id}_page_#{page_number}_chunk_#{chunk_index}"
|
||||
elsif page_number && chunk_index
|
||||
"pdf_chunk_#{SecureRandom.hex(8)}_page_#{page_number}_chunk_#{chunk_index}"
|
||||
else
|
||||
"pdf_chunk_#{SecureRandom.hex(8)}"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_content_title(content, page_number, chunk_index, total_chunks)
|
||||
return 'PDF Content' if content.blank?
|
||||
|
||||
base_title = extract_base_title(content)
|
||||
add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
|
||||
end
|
||||
|
||||
def extract_base_title(content)
|
||||
first_line = content.split("\n").first&.strip || ''
|
||||
|
||||
base_title = if first_line.include?('.')
|
||||
extract_sentence_title(first_line)
|
||||
else
|
||||
first_line
|
||||
end
|
||||
|
||||
# Use generic title for long or blank content
|
||||
base_title.length > 100 || base_title.blank? ? 'PDF Content' : base_title
|
||||
end
|
||||
|
||||
def extract_sentence_title(first_line)
|
||||
first_sentence = first_line.split('.').first&.strip || ''
|
||||
# Only add period if the original content was actually a complete sentence
|
||||
if first_line.split('.').length > 1 && first_line.split('.')[1].strip.present?
|
||||
"#{first_sentence}."
|
||||
else
|
||||
first_sentence
|
||||
end
|
||||
end
|
||||
|
||||
def add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
|
||||
title_with_info = build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
|
||||
truncate_title(title_with_info)
|
||||
end
|
||||
|
||||
def build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
|
||||
return "#{base_title} (Page #{page_number}, Part #{chunk_index}/#{total_chunks})" if total_chunks > 1
|
||||
return "#{base_title} (Page #{page_number})" if page_number && page_number > 1
|
||||
|
||||
base_title
|
||||
end
|
||||
|
||||
def truncate_title(title)
|
||||
title.length > 255 ? "#{title[0, 252]}..." : title
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits.dig(:captain, :documents)
|
||||
return false unless limits
|
||||
|
||||
limits[:current_available].to_i <= 0
|
||||
end
|
||||
|
||||
def handle_record_not_found_error(error, _document_id)
|
||||
Rails.logger.error "Record not found: #{error.message}"
|
||||
raise "Failed to parse PDF data: #{error.message}"
|
||||
end
|
||||
|
||||
def handle_processing_error(error, assistant_id, document_id)
|
||||
Rails.logger.error "Failed to parse PDF content for assistant #{assistant_id}: #{error.message}"
|
||||
|
||||
update_document_error_status(document_id, error) if document_id.present?
|
||||
|
||||
raise "Failed to parse PDF data: #{error.message}"
|
||||
end
|
||||
|
||||
def update_document_error_status(document_id, error)
|
||||
return if should_skip_document_update?(error)
|
||||
|
||||
document = Captain::Document.find_by(id: document_id)
|
||||
document&.update(status: 'in_progress')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to update document status: #{e.message}"
|
||||
end
|
||||
|
||||
def should_skip_document_update?(error)
|
||||
error.message.include?('Database error')
|
||||
end
|
||||
|
||||
def process_document(assistant, content_data, document_id)
|
||||
return create_new_document_with_limit_check(assistant, content_data, document_id) if document_id.blank?
|
||||
|
||||
process_existing_document(assistant, content_data, document_id)
|
||||
end
|
||||
|
||||
def process_existing_document(assistant, content_data, document_id)
|
||||
existing_document = Captain::Document.find_by(id: document_id)
|
||||
|
||||
if existing_document
|
||||
log_chunk_processing(document_id, content_data)
|
||||
update_document_content(existing_document, content_data)
|
||||
else
|
||||
create_new_document_with_limit_check(assistant, content_data, document_id)
|
||||
end
|
||||
end
|
||||
|
||||
def create_new_document_with_limit_check(assistant, content_data, document_id)
|
||||
result = create_new_document(assistant, content_data, document_id)
|
||||
return false if result == false # Limits exceeded
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -4,13 +4,8 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_type :string
|
||||
# document_type :integer default(0), not null
|
||||
# external_link :string not null
|
||||
# file_size :integer
|
||||
# name :string
|
||||
# processed_at :datetime
|
||||
# source_type :string default("url")
|
||||
# status :integer default("in_progress"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -22,9 +17,6 @@
|
||||
# index_captain_documents_on_account_id (account_id)
|
||||
# index_captain_documents_on_assistant_id (assistant_id)
|
||||
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
|
||||
# index_captain_documents_on_content_type (content_type)
|
||||
# index_captain_documents_on_document_type (document_type)
|
||||
# index_captain_documents_on_source_type (source_type)
|
||||
# index_captain_documents_on_status (status)
|
||||
#
|
||||
class Captain::Document < ApplicationRecord
|
||||
@@ -37,10 +29,8 @@ class Captain::Document < ApplicationRecord
|
||||
|
||||
validates :external_link, presence: true
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }
|
||||
validates :content, length: { maximum: 400_000 }
|
||||
validates :source_type, inclusion: { in: %w[url pdf_upload] }
|
||||
validates :content, length: { maximum: 200_000 }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_default_source_type
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -67,8 +57,6 @@ class Captain::Document < ApplicationRecord
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return if status != 'available'
|
||||
# Skip auto-enqueue for PDFs as they handle FAQ generation manually with full content
|
||||
return if pdf_document?
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
@@ -85,37 +73,4 @@ class Captain::Document < ApplicationRecord
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
raise LimitExceededError, 'Document limit exceeded' unless limits[:current_available].positive?
|
||||
end
|
||||
|
||||
def set_default_source_type
|
||||
return if source_type.present?
|
||||
|
||||
# Determine type without calling pdf_document? to avoid circular dependency
|
||||
self.source_type = if content_type&.include?('application/pdf') || pdf_url_format?
|
||||
'pdf_upload'
|
||||
else
|
||||
'url'
|
||||
end
|
||||
end
|
||||
|
||||
# Public method used by CrawlJob to determine if document is a PDF
|
||||
def pdf_document?
|
||||
pdf_upload? || pdf_content_type? || pdf_url_format?
|
||||
end
|
||||
|
||||
def pdf_upload?
|
||||
source_type == 'pdf_upload'
|
||||
end
|
||||
|
||||
def pdf_content_type?
|
||||
content_type&.include?('application/pdf')
|
||||
end
|
||||
|
||||
def pdf_url_format?
|
||||
return false if external_link.blank?
|
||||
|
||||
url = external_link.downcase
|
||||
url.end_with?('.pdf') ||
|
||||
url.include?('/rails/active_storage/blobs/') ||
|
||||
(url.include?('blob') && url.include?('pdf'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,4 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
def playground?
|
||||
true
|
||||
end
|
||||
|
||||
def upload_pdf?
|
||||
@account_user.administrator?
|
||||
end
|
||||
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,60 +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.download_url if attachment.download_url.present?
|
||||
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
|
||||
@@ -1,131 +0,0 @@
|
||||
module Captain::Tools::PdfContentChunkingConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def chunk_content(page_contents, max_chunk_size: self.class::MAX_CHUNK_SIZE)
|
||||
return [] if page_contents.blank?
|
||||
|
||||
all_chunks = []
|
||||
global_chunk_index = 0
|
||||
|
||||
page_contents.each do |page_content|
|
||||
page_chunks = split_content_into_chunks(page_content[:content], max_chunk_size)
|
||||
page_total_chunks = page_chunks.length
|
||||
|
||||
page_chunks.each_with_index do |chunk_content, page_chunk_index|
|
||||
global_chunk_index += 1
|
||||
all_chunks << build_chunk(chunk_content, page_content[:page_number], page_chunk_index + 1, page_total_chunks)
|
||||
end
|
||||
end
|
||||
|
||||
all_chunks
|
||||
end
|
||||
|
||||
def split_content_into_chunks(content, max_size)
|
||||
return [content] if content.length <= max_size
|
||||
|
||||
paragraphs = content.split(/\n\s*\n/)
|
||||
process_paragraphs_into_chunks(paragraphs, max_size, content)
|
||||
end
|
||||
|
||||
def process_paragraphs_into_chunks(paragraphs, max_size, original_content)
|
||||
chunks = []
|
||||
current_chunk = ''
|
||||
|
||||
paragraphs.each do |paragraph|
|
||||
chunks, current_chunk = process_single_paragraph(chunks, current_chunk, paragraph, max_size)
|
||||
end
|
||||
|
||||
finalize_chunks(chunks, current_chunk, original_content)
|
||||
end
|
||||
|
||||
def process_single_paragraph(chunks, current_chunk, paragraph, max_size)
|
||||
return handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size) if paragraph.length > max_size
|
||||
return handle_paragraph_overflow(chunks, current_chunk, paragraph) if paragraph_causes_overflow?(current_chunk, paragraph, max_size)
|
||||
|
||||
combined = combine_paragraph_content(current_chunk, paragraph)
|
||||
[chunks, combined]
|
||||
end
|
||||
|
||||
def finalize_chunks(chunks, current_chunk, original_content)
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
chunks.presence || [original_content]
|
||||
end
|
||||
|
||||
def split_paragraph_into_chunks(paragraph, max_size)
|
||||
sentences = paragraph.split(/(?<=[.!?])\s+/)
|
||||
process_sentences_into_chunks(sentences, max_size)
|
||||
end
|
||||
|
||||
def process_sentences_into_chunks(sentences, max_size)
|
||||
chunks = []
|
||||
current_chunk = ''
|
||||
|
||||
sentences.each do |sentence|
|
||||
chunks, current_chunk = process_single_sentence(chunks, current_chunk, sentence, max_size)
|
||||
end
|
||||
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
chunks
|
||||
end
|
||||
|
||||
def process_single_sentence(chunks, current_chunk, sentence, max_size)
|
||||
return handle_oversized_sentence(chunks, current_chunk, sentence, max_size) if sentence.length > max_size
|
||||
return handle_sentence_overflow(chunks, current_chunk, sentence) if sentence_causes_overflow?(current_chunk, sentence, max_size)
|
||||
|
||||
combined = combine_sentence_content(current_chunk, sentence)
|
||||
[chunks, combined]
|
||||
end
|
||||
|
||||
def handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size)
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
chunks.concat(split_paragraph_into_chunks(paragraph, max_size))
|
||||
[chunks, '']
|
||||
end
|
||||
|
||||
def handle_paragraph_overflow(chunks, current_chunk, paragraph)
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
[chunks, paragraph]
|
||||
end
|
||||
|
||||
def paragraph_causes_overflow?(current_chunk, paragraph, max_size)
|
||||
("#{current_chunk}\n\n#{paragraph}").length > max_size
|
||||
end
|
||||
|
||||
def combine_paragraph_content(current_chunk, paragraph)
|
||||
current_chunk.blank? ? paragraph : "#{current_chunk}\n\n#{paragraph}"
|
||||
end
|
||||
|
||||
def handle_oversized_sentence(chunks, current_chunk, sentence, max_size)
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
chunks << sentence[0, max_size]
|
||||
[chunks, '']
|
||||
end
|
||||
|
||||
def handle_sentence_overflow(chunks, current_chunk, sentence)
|
||||
add_chunk_if_present(chunks, current_chunk)
|
||||
[chunks, sentence]
|
||||
end
|
||||
|
||||
def sentence_causes_overflow?(current_chunk, sentence, max_size)
|
||||
("#{current_chunk} #{sentence}").length > max_size
|
||||
end
|
||||
|
||||
def combine_sentence_content(current_chunk, sentence)
|
||||
current_chunk.blank? ? sentence : "#{current_chunk} #{sentence}"
|
||||
end
|
||||
|
||||
def add_chunk_if_present(chunks, chunk)
|
||||
chunks << chunk.strip if chunk.present?
|
||||
end
|
||||
|
||||
def build_chunk(content, page_number, chunk_index, total_chunks)
|
||||
{
|
||||
content: content,
|
||||
page_number: page_number,
|
||||
chunk_index: chunk_index,
|
||||
total_chunks: total_chunks
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,228 +0,0 @@
|
||||
require 'pdf-reader'
|
||||
|
||||
class Captain::Tools::PdfExtractionService
|
||||
include ActiveModel::Validations
|
||||
include Captain::Tools::PdfValidationConcern
|
||||
include Captain::Tools::PdfContentChunkingConcern
|
||||
|
||||
attr_reader :pdf_source, :errors
|
||||
|
||||
# Fixed PDF processing limits
|
||||
MAX_PDF_SIZE = 25.megabytes
|
||||
MAX_CHUNK_SIZE = 10_000
|
||||
DOWNLOAD_TIMEOUT = 60 # Increased for larger file downloads
|
||||
|
||||
def initialize(pdf_source)
|
||||
@pdf_source = pdf_source
|
||||
@errors = []
|
||||
end
|
||||
|
||||
def perform
|
||||
return failure_response(['Invalid PDF source']) if pdf_source.blank?
|
||||
|
||||
validation_result = validate_pdf_source
|
||||
return validation_result unless validation_result[:success]
|
||||
|
||||
extract_pdf_with_error_handling
|
||||
end
|
||||
|
||||
def extract_pdf_with_error_handling
|
||||
process_pdf_extraction
|
||||
rescue PDF::Reader::MalformedPDFError => e
|
||||
handle_malformed_pdf_error(e)
|
||||
rescue PDF::Reader::UnsupportedFeatureError => e
|
||||
handle_unsupported_feature_error(e)
|
||||
rescue Down::TimeoutError => e
|
||||
handle_timeout_error(e)
|
||||
rescue StandardError => e
|
||||
handle_general_error(e)
|
||||
end
|
||||
|
||||
def extract_text
|
||||
case determine_source_type
|
||||
when :url
|
||||
active_storage_blob_url? ? extract_from_active_storage_blob : extract_from_url
|
||||
when :uploaded_file
|
||||
extract_from_uploaded_file
|
||||
else
|
||||
extract_from_file_path
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_source_type
|
||||
return :url if pdf_source.is_a?(String) && pdf_source.start_with?('http')
|
||||
return :uploaded_file if pdf_source.respond_to?(:tempfile)
|
||||
|
||||
:file_path
|
||||
end
|
||||
|
||||
def extract_from_url
|
||||
temp_file = Down.download(
|
||||
pdf_source,
|
||||
max_size: MAX_PDF_SIZE,
|
||||
open_timeout: DOWNLOAD_TIMEOUT,
|
||||
read_timeout: DOWNLOAD_TIMEOUT
|
||||
)
|
||||
|
||||
begin
|
||||
result = extract_from_file(temp_file.path)
|
||||
ensure
|
||||
temp_file.close
|
||||
temp_file.unlink
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def extract_from_uploaded_file
|
||||
extract_from_file(pdf_source.tempfile.path)
|
||||
end
|
||||
|
||||
def extract_from_file_path
|
||||
extract_from_file(pdf_source)
|
||||
end
|
||||
|
||||
def active_storage_blob_url?
|
||||
pdf_source.include?('/rails/active_storage/blobs/') ||
|
||||
(pdf_source.include?('blob') && pdf_source.include?('pdf'))
|
||||
end
|
||||
|
||||
def extract_from_active_storage_blob
|
||||
blob = find_blob_from_url
|
||||
return extract_blob_content(blob) if blob
|
||||
|
||||
Rails.logger.warn 'ActiveStorage blob not found, falling back to URL download'
|
||||
extract_from_url
|
||||
end
|
||||
|
||||
def extract_blob_content(blob)
|
||||
blob.open { |file| extract_from_file(file.path) }
|
||||
end
|
||||
|
||||
def find_blob_from_url
|
||||
blob_key = extract_blob_key_from_url
|
||||
return nil if blob_key.blank?
|
||||
|
||||
ActiveStorage::Blob.find_by(key: blob_key)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error finding blob with key '#{blob_key}': #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_blob_key_from_url
|
||||
blob_key = if pdf_source.include?('/rails/active_storage/blobs/')
|
||||
extract_key_from_rails_path
|
||||
elsif pdf_source.include?('/blobs/')
|
||||
extract_key_from_blob_path
|
||||
end
|
||||
|
||||
blob_key&.split('?')&.first # Remove query parameters
|
||||
end
|
||||
|
||||
def extract_key_from_rails_path
|
||||
parts = pdf_source.split('/rails/active_storage/blobs/').last.split('/')
|
||||
parts.length > 1 && parts[0] != 'redirect' ? parts[0] : parts[1]
|
||||
end
|
||||
|
||||
def extract_key_from_blob_path
|
||||
pdf_source.split('/blobs/').last.split('/').first
|
||||
end
|
||||
|
||||
def extract_from_file(file_path)
|
||||
text_content = []
|
||||
|
||||
PDF::Reader.open(file_path) do |reader|
|
||||
reader.pages.each_with_index do |page, index|
|
||||
page_content = extract_page_content(page, index)
|
||||
text_content << page_content if page_content
|
||||
end
|
||||
end
|
||||
|
||||
text_content
|
||||
end
|
||||
|
||||
def extract_page_content(page, index)
|
||||
page_text = page.text
|
||||
return nil if page_text.blank?
|
||||
|
||||
cleaned_text = clean_text(page_text)
|
||||
return nil if cleaned_text.blank?
|
||||
|
||||
{ page_number: index + 1, content: cleaned_text }
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to extract text from page #{index + 1}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def clean_text(text)
|
||||
# Remove form feeds and normalize whitespace
|
||||
cleaned = text.tr("\f", "\n")
|
||||
.gsub("\r\n", "\n")
|
||||
.tr("\r", "\n")
|
||||
.gsub(/\s+/, ' ')
|
||||
.gsub(/\n\s*\n\s*\n+/, "\n\n")
|
||||
.strip
|
||||
|
||||
# Remove common PDF artifacts
|
||||
cleaned = cleaned.gsub(/^\d+\s*$/, '') # Remove standalone page numbers
|
||||
.gsub(/^[\s\-_=]+$/, '') # Remove separator lines
|
||||
.strip
|
||||
|
||||
(cleaned.presence)
|
||||
end
|
||||
|
||||
def pdf_source_type
|
||||
case pdf_source
|
||||
when String
|
||||
pdf_source.start_with?('http') ? 'URL' : 'file_path'
|
||||
else
|
||||
'uploaded_file'
|
||||
end
|
||||
end
|
||||
|
||||
def log_extraction_success(chunked_content)
|
||||
total_chars = chunked_content.sum { |chunk| chunk[:content].length }
|
||||
Rails.logger.info "PDF extraction completed: #{chunked_content.length} chunks, #{total_chars} characters"
|
||||
end
|
||||
|
||||
def process_pdf_extraction
|
||||
content = extract_text
|
||||
return failure_response(['No text content found in PDF']) if content.blank?
|
||||
|
||||
chunked_content = chunk_content(content)
|
||||
log_extraction_success(chunked_content)
|
||||
|
||||
success_response(chunked_content)
|
||||
end
|
||||
|
||||
def success_response(content)
|
||||
{ success: true, content: content }
|
||||
end
|
||||
|
||||
def failure_response(errors)
|
||||
{ success: false, errors: errors }
|
||||
end
|
||||
|
||||
def handle_malformed_pdf_error(error)
|
||||
Rails.logger.error "Malformed PDF (#{pdf_source_type}): #{error.message}"
|
||||
failure_response(['Invalid or corrupted PDF format'])
|
||||
end
|
||||
|
||||
def handle_unsupported_feature_error(error)
|
||||
Rails.logger.error "Unsupported PDF feature (#{pdf_source_type}): #{error.message}"
|
||||
failure_response(['PDF contains unsupported features'])
|
||||
end
|
||||
|
||||
def handle_timeout_error(error)
|
||||
Rails.logger.error "PDF download timeout (#{pdf_source_type}): #{error.message}"
|
||||
failure_response(['PDF download timed out'])
|
||||
end
|
||||
|
||||
def handle_general_error(error)
|
||||
Rails.logger.error "PDF extraction error (#{pdf_source_type}): #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
failure_response(['Failed to process PDF'])
|
||||
end
|
||||
end
|
||||
@@ -1,94 +0,0 @@
|
||||
module Captain::Tools::PdfValidationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def validate_pdf_source
|
||||
case determine_source_type
|
||||
when :url then validate_url
|
||||
when :uploaded_file then validate_uploaded_file
|
||||
else validate_file_path
|
||||
end
|
||||
|
||||
{ success: true }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "PDF validation failed: #{e.message}"
|
||||
{ success: false, errors: [e.message] }
|
||||
end
|
||||
|
||||
def validate_url
|
||||
uri = parse_url
|
||||
validate_url_format(uri)
|
||||
validate_url_length
|
||||
validate_url_scheme(uri)
|
||||
end
|
||||
|
||||
def parse_url
|
||||
URI.parse(pdf_source)
|
||||
rescue URI::InvalidURIError
|
||||
raise StandardError, 'Malformed URL'
|
||||
end
|
||||
|
||||
def validate_url_format(uri)
|
||||
raise StandardError, 'Invalid URL format' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def validate_url_length
|
||||
raise StandardError, 'URL too long' if pdf_source.length > 2048
|
||||
end
|
||||
|
||||
def validate_url_scheme(uri)
|
||||
raise StandardError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme)
|
||||
end
|
||||
|
||||
def validate_uploaded_file
|
||||
validate_file_object
|
||||
validate_file_size
|
||||
validate_file_type
|
||||
validate_file_not_empty
|
||||
end
|
||||
|
||||
def validate_file_object
|
||||
raise StandardError, 'File object is invalid' unless pdf_source.respond_to?(:size) && pdf_source.respond_to?(:content_type)
|
||||
end
|
||||
|
||||
def validate_file_size
|
||||
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if pdf_source.size > self.class::MAX_PDF_SIZE
|
||||
end
|
||||
|
||||
def validate_file_type
|
||||
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
|
||||
end
|
||||
|
||||
def validate_file_not_empty
|
||||
raise StandardError, 'Empty file' if pdf_source.respond_to?(:empty?) && pdf_source.empty?
|
||||
end
|
||||
|
||||
def validate_file_path
|
||||
validate_path_presence
|
||||
validate_path_existence
|
||||
validate_path_file_size
|
||||
validate_path_not_empty
|
||||
validate_path_readable
|
||||
end
|
||||
|
||||
def validate_path_presence
|
||||
raise StandardError, 'File path is blank' if pdf_source.blank?
|
||||
end
|
||||
|
||||
def validate_path_existence
|
||||
raise StandardError, 'File does not exist' unless File.exist?(pdf_source)
|
||||
end
|
||||
|
||||
def validate_path_file_size
|
||||
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if File.size(pdf_source) > self.class::MAX_PDF_SIZE
|
||||
end
|
||||
|
||||
def validate_path_not_empty
|
||||
raise StandardError, 'Empty file' if File.empty?(pdf_source)
|
||||
end
|
||||
|
||||
def validate_path_readable
|
||||
raise StandardError, 'File is not readable' unless File.readable?(pdf_source)
|
||||
end
|
||||
end
|
||||
@@ -4,29 +4,10 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
return unless should_process_captain_response?
|
||||
return perform_handoff unless inbox.captain_active?
|
||||
|
||||
schedule_captain_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_captain_response
|
||||
job_args = [conversation, conversation.inbox.captain_assistant]
|
||||
|
||||
if message.attachments.blank?
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
|
||||
else
|
||||
wait_time = calculate_attachment_wait_time
|
||||
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_attachment_wait_time
|
||||
attachment_count = message.attachments.size
|
||||
base_wait = 1.second
|
||||
|
||||
# Wait longer for more attachments or larger files
|
||||
additional_wait = [attachment_count * 1, 4].min.seconds
|
||||
base_wait + additional_wait
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(
|
||||
conversation,
|
||||
conversation.inbox.captain_assistant
|
||||
)
|
||||
end
|
||||
|
||||
def should_process_captain_response?
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
json.partial! 'api/v1/models/captain/document', formats: [:json], resource: @document
|
||||
|
||||
# Include message for PDF uploads
|
||||
json.message 'PDF uploaded successfully. Processing will begin shortly.' if @document.source_type == 'pdf_upload'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Embedded API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /whatsapp/signup' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
it 'returns configuration for embedded signup' do
|
||||
get '/whatsapp/signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { account_id: account.id }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response).to include(
|
||||
'status' => 'ready',
|
||||
'app_id' => 'test_app_id',
|
||||
'config_id' => 'test_config_id',
|
||||
'app_secret' => 'test_app_secret'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get '/whatsapp/signup', params: { account_id: account.id }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /whatsapp/embedded_signup' do
|
||||
let(:params) do
|
||||
{
|
||||
account_id: account.id,
|
||||
code: 'auth_code_123',
|
||||
business_id: '123456789',
|
||||
waba_id: '987654321',
|
||||
phone_number_id: '555444333'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
context 'with missing authorization code' do
|
||||
it 'returns bad request error' do
|
||||
params_without_code = params.except(:code)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_code
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Missing authorization code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing business parameters' do
|
||||
it 'returns bad request when business_id is missing' do
|
||||
params_without_business = params.except(:business_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_business
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
|
||||
it 'returns bad request when waba_id is missing' do
|
||||
params_without_waba = params.except(:waba_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_waba
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post '/whatsapp/embedded_signup', params: params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
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
|
||||
|
||||
@@ -220,16 +220,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
|
||||
context 'with limits exceeded' do
|
||||
before do
|
||||
# Create documents first (when there are no limits)
|
||||
create_list(:captain_document, 5, assistant: assistant, account: account)
|
||||
account.update_document_usage
|
||||
|
||||
# Now set up the limits configuration
|
||||
config = InstallationConfig.find_or_create_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')
|
||||
config.update!(value: captain_limits.to_json)
|
||||
|
||||
account.reload # Reload to ensure changes are reflected
|
||||
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
@@ -295,69 +288,4 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/documents/upload_pdf' do
|
||||
let(:pdf_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/sample.pdf'), 'application/pdf') }
|
||||
let(:valid_pdf_params) do
|
||||
{
|
||||
pdf_document: pdf_file,
|
||||
assistant_id: assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
context 'with valid PDF file' do
|
||||
it 'uploads PDF and creates document' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::Document, :count).by(1)
|
||||
end
|
||||
|
||||
it 'returns success status with document data' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:id]).to be_present
|
||||
expect(json_response[:message]).to eq('PDF uploaded successfully. Processing will begin shortly.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'without PDF file' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: admin.create_new_auth_token
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,142 +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
|
||||
|
||||
describe 'retry mechanisms for image processing' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello with image', message_type: :incoming)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
|
||||
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
|
||||
end
|
||||
|
||||
context 'when ActiveStorage::FileNotFoundError occurs' do
|
||||
it 'handles file errors and triggers handoff' do
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(ActiveStorage::FileNotFoundError, 'Image file not found')
|
||||
|
||||
# For retryable errors, the job should handle them and proceed with handoff
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
# Verify handoff occurred due to repeated failures
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'succeeds when no error occurs' do
|
||||
# Don't raise any error, should succeed normally
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_return('Image content processed successfully')
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Test response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Faraday::BadRequestError occurs' do
|
||||
it 'handles API errors and triggers handoff' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'succeeds when no error occurs' do
|
||||
# Don't raise any error, should succeed normally
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return({ 'response' => 'Response after retry' })
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Response after retry')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image processing fails permanently' do
|
||||
before do
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(ActiveStorage::FileNotFoundError, 'Image permanently unavailable')
|
||||
end
|
||||
|
||||
it 'triggers handoff after max retries' do
|
||||
# Since perform_now re-raises retryable errors, simulate the final failure after retries
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(StandardError, 'Max retries exceeded')
|
||||
|
||||
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when non-retryable error occurs' do
|
||||
let(:standard_error) { StandardError.new('Generic error') }
|
||||
|
||||
before do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(standard_error)
|
||||
end
|
||||
|
||||
it 'handles error and triggers handoff' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new)
|
||||
.with(standard_error, account: account)
|
||||
.and_call_original
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'ensures Current.executed_by is reset' do
|
||||
expect(Current).to receive(:executed_by=).with(assistant)
|
||||
expect(Current).to receive(:executed_by=).with(nil)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'has retry_on configuration for retryable errors' do
|
||||
expect(described_class).to respond_to(:retry_on)
|
||||
end
|
||||
|
||||
it 'defines MAX_MESSAGE_LENGTH constant' do
|
||||
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -104,131 +104,6 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
expect(simple_crawler).to receive(:page_links)
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
context 'when document is a PDF' do
|
||||
let(:pdf_document) { create(:captain_document, external_link: 'https://example.com/document.pdf') }
|
||||
let(:pdf_service) { instance_double(Captain::Tools::PdfExtractionService) }
|
||||
let(:pdf_content) do
|
||||
[
|
||||
{ content: 'PDF page 1 content', page_number: 1, chunk_index: 1, total_chunks: 1 },
|
||||
{ content: 'PDF page 2 content', page_number: 2, chunk_index: 1, total_chunks: 1 }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Tools::PdfExtractionService)
|
||||
.to receive(:new)
|
||||
.with(pdf_document.external_link)
|
||||
.and_return(pdf_service)
|
||||
end
|
||||
|
||||
it 'processes PDF using PdfExtractionService when extraction succeeds' do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: true,
|
||||
content: pdf_content
|
||||
})
|
||||
expect(pdf_service).to receive(:perform)
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'enqueues PdfExtractionParserJob for each content chunk when extraction succeeds' do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: true,
|
||||
content: pdf_content
|
||||
})
|
||||
pdf_content.each do |chunk|
|
||||
expect(Captain::Tools::PdfExtractionParserJob)
|
||||
.to receive(:perform_later)
|
||||
.with(
|
||||
assistant_id: pdf_document.assistant_id,
|
||||
pdf_content: chunk,
|
||||
document_id: pdf_document.id
|
||||
)
|
||||
end
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'updates document status to processing when extraction succeeds' do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: true,
|
||||
content: pdf_content
|
||||
})
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(pdf_document).to receive(:update).with(status: 'processing').twice
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'updates document status to failed with error message when extraction fails' do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: false,
|
||||
errors: ['Invalid PDF format']
|
||||
})
|
||||
expect(pdf_document).to receive(:update).with(status: 'processing').once
|
||||
expect(pdf_document).to receive(:update).with(
|
||||
status: 'failed',
|
||||
error_message: 'Invalid PDF format'
|
||||
).once
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'logs the error when extraction fails' do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: false,
|
||||
errors: ['Invalid PDF format']
|
||||
})
|
||||
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'handles exceptions gracefully during PDF extraction' do
|
||||
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
|
||||
expect(pdf_document).to receive(:update).with(status: 'processing').once
|
||||
expect(pdf_document).to receive(:update).with(
|
||||
status: 'failed',
|
||||
error_message: 'Network error'
|
||||
).once
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'logs exceptions during PDF extraction' do
|
||||
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
|
||||
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#pdf_document?' do
|
||||
let(:job) { described_class.new }
|
||||
|
||||
it 'detects PDF by file extension' do
|
||||
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.pdf')
|
||||
expect(job.send(:pdf_document?, pdf_doc)).to be true
|
||||
end
|
||||
|
||||
it 'detects PDF by content type' do
|
||||
pdf_doc = build(:captain_document, content_type: 'application/pdf')
|
||||
expect(job.send(:pdf_document?, pdf_doc)).to be true
|
||||
end
|
||||
|
||||
it 'detects PDF by source type' do
|
||||
pdf_doc = build(:captain_document, source_type: 'pdf_upload')
|
||||
expect(job.send(:pdf_document?, pdf_doc)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-PDF documents' do
|
||||
web_doc = build(:captain_document, external_link: 'https://example.com/page.html')
|
||||
expect(job.send(:pdf_document?, web_doc)).to be false
|
||||
end
|
||||
|
||||
it 'is case insensitive for file extensions' do
|
||||
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.PDF')
|
||||
expect(job.send(:pdf_document?, pdf_doc)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::PdfExtractionParserJob, type: :job do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:pdf_content) do
|
||||
{
|
||||
content: 'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.',
|
||||
page_number: 1,
|
||||
chunk_index: 1,
|
||||
total_chunks: 1
|
||||
}
|
||||
end
|
||||
|
||||
let(:captain_limits) do
|
||||
{
|
||||
startups: { documents: 5, responses: 100 }
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock usage limits
|
||||
allow(account).to receive(:usage_limits).and_return(
|
||||
captain: {
|
||||
documents: {
|
||||
current_available: 3
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when limits are not exceeded' do
|
||||
it 'processes PDF content successfully' do
|
||||
expect do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
end.to change(Captain::Document, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates captain documents with correct attributes' do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
|
||||
document = Captain::Document.last
|
||||
expect(document.assistant_id).to eq(assistant.id)
|
||||
expect(document.content).to include('sample PDF content')
|
||||
expect(document.name).to include('This is sample PDF content extracted from a document')
|
||||
expect(document.status).to eq('available')
|
||||
end
|
||||
|
||||
it 'generates appropriate title for single chunk' do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
|
||||
document = Captain::Document.last
|
||||
expect(document.name).to eq('This is sample PDF content extracted from a document.')
|
||||
end
|
||||
|
||||
it 'generates appropriate title for multiple chunks' do
|
||||
multi_chunk_content = pdf_content.merge(
|
||||
chunk_index: 2,
|
||||
total_chunks: 3,
|
||||
page_number: 2
|
||||
)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: multi_chunk_content
|
||||
)
|
||||
|
||||
document = Captain::Document.last
|
||||
expect(document.name).to include('Page 2, Part 2/3')
|
||||
end
|
||||
|
||||
it 'creates document with unique external link' do
|
||||
document_id = 123
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: document_id
|
||||
)
|
||||
|
||||
document = Captain::Document.last
|
||||
expect(document.external_link).to eq("pdf_chunk_#{document_id}_page_1_chunk_1")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when limits are exceeded' do
|
||||
before do
|
||||
# Stub the Captain::Document.create! method to raise limit error
|
||||
# This is more specific than allow_any_instance_of
|
||||
allow(Captain::Document).to receive(:create!).and_raise(Captain::Document::LimitExceededError, 'Document limit exceeded')
|
||||
end
|
||||
|
||||
it 'does not create documents when limit exceeded' do
|
||||
expect do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
end.not_to(change(Captain::Document, :count))
|
||||
end
|
||||
|
||||
it 'logs limit exceeded message' do
|
||||
expect(Rails.logger).to receive(:info).with(/Document limit exceeded/)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document_id is provided' do
|
||||
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
|
||||
|
||||
it 'updates document status to available' do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: document.id
|
||||
)
|
||||
|
||||
document.reload
|
||||
expect(document.status).to eq('available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
|
||||
|
||||
before do
|
||||
allow(Captain::Document).to receive(:create!).and_raise(StandardError, 'Database error')
|
||||
# Mock the specific document instance that will be found by the job
|
||||
allow(Captain::Document).to receive(:find_by).with(id: document.id).and_return(document)
|
||||
allow(document).to receive(:update!).and_raise(StandardError, 'Database error')
|
||||
end
|
||||
|
||||
it 'raises an error with descriptive message' do
|
||||
expect do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: document.id
|
||||
)
|
||||
end.to raise_error(/Failed to parse PDF data/)
|
||||
end
|
||||
|
||||
it 'raises error and updates main document if provided' do
|
||||
expect do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: document.id
|
||||
)
|
||||
end.to raise_error(/Failed to parse PDF data/)
|
||||
|
||||
# The main document should remain in its original state when an error occurs
|
||||
document.reload
|
||||
expect(document.status).to eq('in_progress')
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/Failed to parse PDF content/).at_least(:once)
|
||||
|
||||
begin
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content
|
||||
)
|
||||
rescue StandardError
|
||||
# Expected to raise error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
let(:job) { described_class.new }
|
||||
|
||||
describe '#generate_content_title' do
|
||||
it 'uses first line as title when appropriate' do
|
||||
content = "Introduction to Machine Learning\n\nThis document covers the basics..."
|
||||
title = job.send(:generate_content_title, content, 1, 1, 1)
|
||||
expect(title).to eq('Introduction to Machine Learning')
|
||||
end
|
||||
|
||||
it 'uses generic title for long first lines' do
|
||||
content = "#{('A' * 200)}\n\nThis is the rest of the content..."
|
||||
title = job.send(:generate_content_title, content, 1, 1, 1)
|
||||
expect(title).to eq('PDF Content')
|
||||
end
|
||||
|
||||
it 'adds page information for multiple pages' do
|
||||
content = 'Sample content'
|
||||
title = job.send(:generate_content_title, content, 3, 1, 1)
|
||||
expect(title).to eq('Sample content (Page 3)')
|
||||
end
|
||||
|
||||
it 'adds chunk information for multiple chunks' do
|
||||
content = 'Sample content'
|
||||
title = job.send(:generate_content_title, content, 2, 2, 4)
|
||||
expect(title).to eq('Sample content (Page 2, Part 2/4)')
|
||||
end
|
||||
|
||||
it 'truncates long titles to database limit' do
|
||||
long_content = 'A' * 300
|
||||
title = job.send(:generate_content_title, long_content, 1, 1, 1)
|
||||
expect(title.length).to be <= 255
|
||||
end
|
||||
end
|
||||
|
||||
describe '#limit_exceeded?' do
|
||||
it 'returns true when limit is zero' do
|
||||
allow(account).to receive(:usage_limits).and_return(
|
||||
captain: { documents: { current_available: 0 } }
|
||||
)
|
||||
expect(job.send(:limit_exceeded?, account)).to be true
|
||||
end
|
||||
|
||||
it 'returns true when limit is negative' do
|
||||
allow(account).to receive(:usage_limits).and_return(
|
||||
captain: { documents: { current_available: -1 } }
|
||||
)
|
||||
expect(job.send(:limit_exceeded?, account)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when limit is positive' do
|
||||
allow(account).to receive(:usage_limits).and_return(
|
||||
captain: { documents: { current_available: 5 } }
|
||||
)
|
||||
expect(job.send(:limit_exceeded?, account)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,310 +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')
|
||||
allow(attachment).to receive(:download_url).and_return('')
|
||||
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
|
||||
@@ -1,247 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::PdfExtractionService do
|
||||
let(:service) { described_class.new(pdf_source) }
|
||||
let(:sample_pdf_path) { Rails.root.join('spec/fixtures/files/sample.pdf') }
|
||||
|
||||
describe '#initialize' do
|
||||
context 'with valid PDF path' do
|
||||
let(:pdf_source) { sample_pdf_path.to_s }
|
||||
|
||||
it 'initializes with the PDF source' do
|
||||
expect(service.pdf_source).to eq(pdf_source)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with URL' do
|
||||
let(:pdf_source) { 'https://example.com/sample.pdf' }
|
||||
|
||||
it 'initializes with the PDF URL' do
|
||||
expect(service.pdf_source).to eq(pdf_source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with blank PDF source' do
|
||||
let(:pdf_source) { nil }
|
||||
|
||||
it 'returns error for blank source' do
|
||||
result = service.perform
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:errors]).to include('Invalid PDF source')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid PDF file path' do
|
||||
let(:pdf_source) { sample_pdf_path.to_s }
|
||||
|
||||
before do
|
||||
# Ensure the sample PDF exists
|
||||
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
|
||||
end
|
||||
|
||||
it 'handles PDF extraction gracefully' do
|
||||
result = service.perform
|
||||
expect(result).to have_key(:success)
|
||||
expect(result).to have_key(:content).or have_key(:errors)
|
||||
|
||||
if result[:success]
|
||||
expect(result[:content]).to be_an(Array)
|
||||
else
|
||||
expect(result[:errors]).to be_an(Array)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns structured result format' do
|
||||
result = service.perform
|
||||
|
||||
if result[:success] && result[:content].present?
|
||||
first_chunk = result[:content].first
|
||||
expect(first_chunk).to have_key(:content)
|
||||
expect(first_chunk).to have_key(:page_number)
|
||||
expect(first_chunk).to have_key(:chunk_index)
|
||||
expect(first_chunk).to have_key(:total_chunks)
|
||||
else
|
||||
expect(result[:errors]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed PDF' do
|
||||
let(:pdf_source) { Rails.root.join('spec/fixtures/files/sample.txt').to_s }
|
||||
|
||||
it 'handles malformed PDF gracefully' do
|
||||
result = service.perform
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:errors]).to be_an(Array)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with HTTP URL' do
|
||||
let(:pdf_source) { 'https://example.com/sample.pdf' }
|
||||
|
||||
it 'attempts to download and process PDF from URL' do
|
||||
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s, close: nil, unlink: nil)
|
||||
allow(Down).to receive(:download).and_return(temp_file)
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
|
||||
# Mock PDF reader
|
||||
mock_page = instance_double(PDF::Reader::Page, text: 'Sample PDF content')
|
||||
mock_pages = [mock_page]
|
||||
mock_reader = instance_double(PDF::Reader, pages: mock_pages)
|
||||
allow(PDF::Reader).to receive(:open).and_yield(mock_reader)
|
||||
|
||||
result = service.perform
|
||||
expect(result[:success]).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_text' do
|
||||
let(:pdf_source) { sample_pdf_path.to_s }
|
||||
|
||||
before do
|
||||
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
|
||||
end
|
||||
|
||||
it 'handles text extraction gracefully' do
|
||||
expect { service.extract_text }.not_to raise_error(NoMethodError)
|
||||
|
||||
# Should either succeed or raise a PDF::Reader error that gets caught
|
||||
begin
|
||||
extracted_content = service.extract_text
|
||||
expect(extracted_content).to be_an(Array)
|
||||
|
||||
if extracted_content.any?
|
||||
page_content = extracted_content.first
|
||||
expect(page_content).to have_key(:page_number)
|
||||
expect(page_content).to have_key(:content)
|
||||
end
|
||||
rescue PDF::Reader::MalformedPDFError
|
||||
# This is expected for malformed PDFs
|
||||
# Test passes if we reach this point
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
let(:pdf_source) { sample_pdf_path.to_s }
|
||||
|
||||
describe '#clean_text' do
|
||||
it 'cleans text content properly' do
|
||||
dirty_text = " \f Some text with \r\n line breaks \n\n\n and extra spaces "
|
||||
cleaned = service.send(:clean_text, dirty_text)
|
||||
|
||||
expect(cleaned).not_to include("\f")
|
||||
expect(cleaned).not_to include("\r")
|
||||
expect(cleaned).to include('Some text with')
|
||||
expect(cleaned.strip).to eq(cleaned)
|
||||
end
|
||||
|
||||
it 'returns nil for blank text' do
|
||||
cleaned = service.send(:clean_text, " \n\n ")
|
||||
expect(cleaned).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#chunk_content' do
|
||||
let(:page_contents) do
|
||||
[
|
||||
{ page_number: 1, content: 'Short content' },
|
||||
{ page_number: 2, content: 'A' * 3000 } # Long content that needs chunking
|
||||
]
|
||||
end
|
||||
|
||||
it 'chunks content appropriately' do
|
||||
chunks = service.send(:chunk_content, page_contents, max_chunk_size: 1000)
|
||||
|
||||
expect(chunks.length).to be >= 2 # Should have at least 2 chunks
|
||||
|
||||
# Check first chunk (short content)
|
||||
first_chunk = chunks.first
|
||||
expect(first_chunk[:page_number]).to eq(1)
|
||||
expect(first_chunk[:total_chunks]).to eq(1)
|
||||
|
||||
# Check that long content was processed
|
||||
long_content_chunks = chunks.select { |c| c[:page_number] == 2 }
|
||||
expect(long_content_chunks.length).to be >= 1
|
||||
|
||||
# Verify long content was split appropriately
|
||||
total_long_content_length = long_content_chunks.sum { |c| c[:content].length }
|
||||
expect(total_long_content_length).to be > 0
|
||||
end
|
||||
end
|
||||
|
||||
describe '#split_content_into_chunks' do
|
||||
it 'splits content by paragraphs first' do
|
||||
content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
|
||||
chunks = service.send(:split_content_into_chunks, content, 50)
|
||||
|
||||
expect(chunks.length).to be >= 2
|
||||
expect(chunks.join(' ')).to include('First paragraph')
|
||||
expect(chunks.join(' ')).to include('Second paragraph')
|
||||
end
|
||||
|
||||
it 'splits by sentences when paragraphs are too large' do
|
||||
long_paragraph = "#{('A' * 100)}. #{('B' * 100)}. #{('C' * 100)}."
|
||||
chunks = service.send(:split_content_into_chunks, long_paragraph, 150)
|
||||
|
||||
expect(chunks.length).to be > 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'file validation' do
|
||||
context 'with uploaded file object' do
|
||||
let(:uploaded_file) do
|
||||
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
|
||||
instance_double(
|
||||
ActionDispatch::Http::UploadedFile,
|
||||
tempfile: temp_file,
|
||||
content_type: 'application/pdf',
|
||||
size: 1024
|
||||
)
|
||||
end
|
||||
let(:pdf_source) { uploaded_file }
|
||||
|
||||
it 'validates uploaded file properties' do
|
||||
expect { service.send(:validate_uploaded_file) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with oversized file' do
|
||||
let(:uploaded_file) do
|
||||
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
|
||||
instance_double(
|
||||
ActionDispatch::Http::UploadedFile,
|
||||
tempfile: temp_file,
|
||||
content_type: 'application/pdf',
|
||||
size: 30.megabytes
|
||||
)
|
||||
end
|
||||
let(:pdf_source) { uploaded_file }
|
||||
|
||||
it 'raises error for oversized file' do
|
||||
expect { service.send(:validate_uploaded_file) }.to raise_error(/File too large/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid content type' do
|
||||
let(:uploaded_file) do
|
||||
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
|
||||
instance_double(
|
||||
ActionDispatch::Http::UploadedFile,
|
||||
tempfile: temp_file,
|
||||
content_type: 'text/plain',
|
||||
size: 1024
|
||||
)
|
||||
end
|
||||
let(:pdf_source) { uploaded_file }
|
||||
|
||||
it 'raises error for invalid content type' do
|
||||
expect { service.send(:validate_uploaded_file) }.to raise_error('Invalid file type')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Vendored
-53
@@ -1,53 +0,0 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 44
|
||||
>>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
72 720 Td
|
||||
(Sample PDF for testing) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000202 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
294
|
||||
%%EOF
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
This is a sample text file for testing purposes.
|
||||
It contains some basic text content that can be used in tests.
|
||||
Vendored
-62
@@ -1,62 +0,0 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 4 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 5 0 R
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 54
|
||||
>>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
72 720 Td
|
||||
(Hello World from PDF\!) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000060 00000 n
|
||||
0000000117 00000 n
|
||||
0000000254 00000 n
|
||||
0000000350 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 6
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
421
|
||||
%%EOF
|
||||
@@ -262,7 +262,7 @@ RSpec.describe Inbox do
|
||||
describe '#sanitized_name' do
|
||||
context 'when inbox name contains forbidden characters' do
|
||||
it 'removes forbidden and spam-trigger characters' do
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\'Quotes!#$%')
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
|
||||
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::EmbeddedSignupService do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { 'test_authorization_code' }
|
||||
let(:business_id) { 'test_business_id' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:phone_number_id) { 'test_phone_number_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:app_id) { 'test_app_id' }
|
||||
let(:app_secret) { 'test_app_secret' }
|
||||
let(:api_version) { 'v22.0' }
|
||||
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock global configuration
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
# Mock environment variables - allow any calls to ENV.fetch
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
|
||||
allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
|
||||
|
||||
# Mock ChatwootApp enterprise checks
|
||||
allow(ChatwootApp).to receive(:enterprise?).and_return(false)
|
||||
|
||||
# NOTE: Specific HTTP request stubs are defined in individual test contexts
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when all parameters are valid' do
|
||||
before do
|
||||
# Stub the token exchange
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the phone numbers fetch
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the token validation
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the provider validation request (WhatsApp Cloud)
|
||||
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the phone number registration
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the webhook subscription
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'successfully creates a new WhatsApp channel' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
expect(channel).not_to be_nil
|
||||
expect(channel.provider).to eq('whatsapp_cloud')
|
||||
expect(channel.provider_config['api_key']).to eq(access_token)
|
||||
expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
|
||||
expect(channel.provider_config['business_account_id']).to eq(waba_id)
|
||||
expect(channel.provider_config['source']).to eq('embedded_signup')
|
||||
end
|
||||
|
||||
it 'creates an inbox for the channel' do
|
||||
service.perform
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
inbox = Inbox.find_by(account: account, channel: channel)
|
||||
expect(inbox).not_to be_nil
|
||||
expect(inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
|
||||
it 'registers the phone number' do
|
||||
service.perform
|
||||
|
||||
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
pin: '212834'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets up webhook subscription' do
|
||||
service.perform
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
|
||||
|
||||
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
body: hash_including(
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: channel.provider_config['webhook_verify_token']
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises an error when code is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: '',
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when business_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: '',
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when waba_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: '',
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when phone_number_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: ''
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel already exists' do
|
||||
before do
|
||||
# Stub all the required requests for successful flow
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub 360Dialog provider validation (for existing channel creation)
|
||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { templates: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Create existing channel
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Channel already exists/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token exchange fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token exchange failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token has no access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone numbers fetch fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook override fails' do
|
||||
before do
|
||||
# Stub all the successful requests
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the failing webhook request
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Webhook override failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#exchange_code_for_token' do
|
||||
context 'when token exchange is successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the access token' do
|
||||
result = service.send(:exchange_code_for_token)
|
||||
expect(result).to eq(access_token)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response has no access token' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { some_other_field: 'value' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_phone_info_via_waba' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns formatted phone info' do
|
||||
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
expect(result).to eq({
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when specific phone number is not found' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 'different_phone_id',
|
||||
display_phone_number: '9876543210',
|
||||
verified_name: 'Different Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number' do
|
||||
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
expect(result[:phone_number_id]).to eq('different_phone_id')
|
||||
expect(result[:phone_number]).to eq('+9876543210')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no phone numbers are available' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
end.to raise_error(/No phone numbers found for WABA/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate_token_waba_access' do
|
||||
context 'when token has access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'validates successfully when token has access' do
|
||||
expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token validation fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/Token validation failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error when WABA ID is not in target_ids' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'some_other_scope',
|
||||
target_ids: ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/No WABA scope found in token/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user