Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c351b6df5f | ||
|
|
fcc13a5064 | ||
|
|
ac83c28466 | ||
|
|
ac9c24e0aa | ||
|
|
8c43a6270d | ||
|
|
599560522f | ||
|
|
e4d778de21 | ||
|
|
84383160bd | ||
|
|
49939c8df7 | ||
|
|
f94691aecb | ||
|
|
ed79173b1b | ||
|
|
d54b32432a | ||
|
|
b7fdeb79c2 | ||
|
|
0eedac48be | ||
|
|
f8ad34a0b0 | ||
|
|
07b1fc202e | ||
|
|
4548ff35f9 | ||
|
|
dd888f2cf0 | ||
|
|
e86f9c3005 | ||
|
|
b9d3a34a73 | ||
|
|
46dba4998b | ||
|
|
1931a170fa | ||
|
|
62c5d40b2c | ||
|
|
50d023caed | ||
|
|
9007bf1ecf | ||
|
|
d696fa2b17 | ||
|
|
a571396b71 | ||
|
|
5c8df87cdc | ||
|
|
4a36fd6373 | ||
|
|
13918c046d | ||
|
|
ff3780ab1b | ||
|
|
8952bc421c | ||
|
|
358ec8120b | ||
|
|
71f64fd00a | ||
|
|
c73f33619d | ||
|
|
3adad0f32a | ||
|
|
69b260a019 | ||
|
|
2e0bc94b2d | ||
|
|
4896ecd033 | ||
|
|
1b7ad7c944 | ||
|
|
a347416667 | ||
|
|
69b762cfdf | ||
|
|
e15a334daf | ||
|
|
99a3a2a69b | ||
|
|
c8f0cbbfa1 | ||
|
|
11e01f746e | ||
|
|
6400585bd7 | ||
|
|
e8196b9537 | ||
|
|
3fca0ab797 | ||
|
|
abeaa25c6a | ||
|
|
2663ad8e56 | ||
|
|
3dbb030900 | ||
|
|
92fcfa1e59 | ||
|
|
eb3360fcc4 | ||
|
|
ac99250fac | ||
|
|
e49a2eb606 | ||
|
|
c08d39d5b8 | ||
|
|
eb0064ddf4 | ||
|
|
8f807aab17 | ||
|
|
250d9d4dee | ||
|
|
5762d1dd50 | ||
|
|
91cb159ef0 | ||
|
|
45f9aeec10 | ||
|
|
553fcbc5e9 | ||
|
|
ba65a5a534 | ||
|
|
6656b042b8 | ||
|
|
9370d21612 | ||
|
|
6681561864 | ||
|
|
6222f9b492 | ||
|
|
dc48d44b51 | ||
|
|
3765720849 | ||
|
|
2f59d34e50 | ||
|
|
ec107a2d94 | ||
|
|
235ce3479c | ||
|
|
8e87e16486 | ||
|
|
8f27ba081c | ||
|
|
6c45d56567 | ||
|
|
85c2fd10e4 | ||
|
|
165a74229e | ||
|
|
de6717e7f1 | ||
|
|
7d39f890e4 | ||
|
|
340fcb6152 | ||
|
|
a92394f446 | ||
|
|
ea3a84c930 | ||
|
|
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 |
+1
-1
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/TrailingEmptyLines:
|
||||
Enabled: false
|
||||
Enabled: true
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
@@ -174,6 +174,8 @@ 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'
|
||||
|
||||
+14
-1
@@ -25,6 +25,7 @@ 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)
|
||||
@@ -126,6 +127,7 @@ 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)
|
||||
@@ -365,6 +367,7 @@ 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)
|
||||
@@ -554,6 +557,12 @@ 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)
|
||||
@@ -711,6 +720,7 @@ 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)
|
||||
@@ -819,6 +829,8 @@ 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)
|
||||
@@ -963,6 +975,7 @@ 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
|
||||
@@ -1024,4 +1037,4 @@ RUBY VERSION
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.16
|
||||
2.6.7
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
# Handles the embedded signup callback data from the Facebook SDK
|
||||
def create
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
render_success_response(channel.inbox)
|
||||
rescue StandardError => e
|
||||
render_error_response(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_embedded_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 render_success_response(inbox)
|
||||
render json: {
|
||||
success: true,
|
||||
id: inbox.id,
|
||||
name: inbox.name,
|
||||
channel_type: 'whatsapp'
|
||||
}
|
||||
end
|
||||
|
||||
def render_error_response(error)
|
||||
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
render json: {
|
||||
success: false,
|
||||
error: error.message
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_feature_enabled!
|
||||
return if Current.account.feature_whatsapp_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
error: 'WhatsApp embedded signup is not enabled for this account'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_embedded_signup_params!
|
||||
missing_params = []
|
||||
missing_params << 'code' if params[:code].blank?
|
||||
missing_params << 'business_id' if params[:business_id].blank?
|
||||
missing_params << 'waba_id' if params[:waba_id].blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -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])
|
||||
|
||||
@@ -15,6 +15,14 @@ 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 |
@@ -26,6 +26,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -63,6 +67,22 @@ const menuItems = computed(() => {
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const displayLink = computed(() => {
|
||||
// For PDF uploads, show the filename instead of the generated external_link
|
||||
if (props.file?.filename) {
|
||||
return props.file.filename;
|
||||
}
|
||||
return props.externalLink;
|
||||
});
|
||||
|
||||
const actualLink = computed(() => {
|
||||
// For PDF uploads, return the actual file URL; for URLs, return the external_link
|
||||
if (props.file?.url) {
|
||||
return props.file.url;
|
||||
}
|
||||
return props.externalLink;
|
||||
});
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
@@ -103,12 +123,15 @@ const handleAction = ({ action, value }) => {
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<span
|
||||
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
|
||||
<a
|
||||
:href="actualLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1 hover:text-n-slate-12 transition-colors"
|
||||
>
|
||||
<i class="i-ph-link-simple shrink-0" />
|
||||
<span class="truncate">{{ externalLink }}</span>
|
||||
</span>
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</a>
|
||||
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
</div>
|
||||
|
||||
+26
-11
@@ -2,28 +2,43 @@
|
||||
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']);
|
||||
const { t } = useI18n();
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const documentForm = ref(null);
|
||||
|
||||
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
|
||||
const handleSubmit = async newDocument => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/create', newDocument);
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
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');
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
error?.response?.data?.message ||
|
||||
'Failed to create document. Please try again.';
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
@@ -42,8 +57,8 @@ defineExpose({ dialogRef });
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
|
||||
:title="$t('CAPTAIN.DOCUMENTS.CREATE.TITLE')"
|
||||
:description="$t('CAPTAIN.DOCUMENTS.CREATE.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
|
||||
+227
-31
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, url } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -8,27 +7,36 @@ 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 = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
selectedFile: null,
|
||||
sourceType: 'url', // 'url' or 'pdf'
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const uploadError = ref('');
|
||||
|
||||
const validationRules = {
|
||||
url: { required, url, minLength: minLength(1) },
|
||||
assistantId: { required },
|
||||
};
|
||||
const validationRules = computed(() => {
|
||||
const rules = {
|
||||
assistantId: { required },
|
||||
};
|
||||
|
||||
if (state.sourceType === 'url') {
|
||||
rules.url = { required, url, minLength: minLength(1) };
|
||||
}
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
@@ -41,70 +49,258 @@ const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
const getErrorMessage = (field, errorMessage) => {
|
||||
return v$.value[field]?.$error ? errorMessage : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
url: getErrorMessage('url', 'Please enter a valid URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'Please select an assistant'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareDocumentDetails = () => ({
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
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 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">
|
||||
<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'"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
<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.FORM.ASSISTANT.LABEL') }}
|
||||
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="assistant"
|
||||
v-model="state.assistantId"
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
placeholder="Choose an assistant"
|
||||
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="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
label="Cancel"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="t('CAPTAIN.FORM.CREATE')"
|
||||
label="Create Document"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<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>
|
||||
@@ -0,0 +1,354 @@
|
||||
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 = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
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(
|
||||
`/api/v1/accounts/${accountId}/whatsapp/authorization`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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',
|
||||
'web.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,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"PLACEHOLDER": "Search",
|
||||
"EMPTY_STATE": "No results found"
|
||||
},
|
||||
"CLOSE": "Close"
|
||||
"CLOSE": "Close",
|
||||
"FILE_SIZE": {
|
||||
"MB": "MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +501,26 @@
|
||||
},
|
||||
"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": "Add a document",
|
||||
"TITLE": "Create New Document",
|
||||
"DESCRIPTION": "Add a new document from a website URL or by uploading a PDF file",
|
||||
"SUCCESS_MESSAGE": "The document has been successfully created",
|
||||
"ERROR_MESSAGE": "There was an error creating the document, please try again."
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
"URL": {
|
||||
|
||||
@@ -154,6 +154,7 @@ onMounted(() => {
|
||||
:key="doc.id"
|
||||
:name="doc.name || doc.external_link"
|
||||
:external-link="doc.external_link"
|
||||
:file="doc.file"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
@action="handleAction"
|
||||
|
||||
@@ -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,163 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
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 store = useStore();
|
||||
|
||||
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 isWhatsappEmbeddedSignupEnabled = computed(() => {
|
||||
const accountId = route.params.accountId;
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
'whatsapp_embedded_signup'
|
||||
);
|
||||
});
|
||||
|
||||
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 => {
|
||||
// Check if the feature is enabled for the account
|
||||
if (!isWhatsappEmbeddedSignupEnabled.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
|
||||
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show cloud whatsapp when:
|
||||
// 1. Provider is whatsapp AND
|
||||
// 2. Either no app ID is configured OR embedded signup feature is disabled
|
||||
return provider === PROVIDER_TYPES.WHATSAPP;
|
||||
};
|
||||
</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,7 +1,25 @@
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validate :validate_provider_config
|
||||
|
||||
after_create :sync_templates
|
||||
after_create_commit :setup_webhooks
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@@ -67,4 +68,24 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
def validate_provider_config
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
def setup_webhooks
|
||||
return unless provider == 'whatsapp_cloud'
|
||||
|
||||
# Only setup webhooks for embedded signup flow
|
||||
# Manual setup flow expects users to configure webhooks themselves
|
||||
return unless provider_config['source'] == 'embedded_signup'
|
||||
|
||||
# Only setup webhooks if we have the necessary configuration
|
||||
business_account_id = provider_config['business_account_id']
|
||||
api_key = provider_config['api_key']
|
||||
|
||||
return if business_account_id.blank? || api_key.blank?
|
||||
|
||||
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
|
||||
# Don't raise the error to prevent channel creation from failing
|
||||
# Webhooks can be retried later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,75 @@
|
||||
class Whatsapp::ChannelCreationService
|
||||
def initialize(account, waba_info, phone_info, access_token)
|
||||
@account = account
|
||||
@waba_info = waba_info
|
||||
@phone_info = phone_info
|
||||
@access_token = access_token
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
existing_channel = find_existing_channel
|
||||
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
|
||||
|
||||
create_channel_with_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Account is required' if @account.blank?
|
||||
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
|
||||
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_existing_channel
|
||||
Channel::Whatsapp.find_by(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number]
|
||||
)
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
create_inbox(channel)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel
|
||||
Channel::Whatsapp.create!(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: build_provider_config
|
||||
)
|
||||
end
|
||||
|
||||
def build_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_inbox(channel)
|
||||
inbox_name = build_inbox_name
|
||||
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def build_inbox_name
|
||||
business_name = @phone_info[:business_name] || @waba_info[:business_name]
|
||||
"#{business_name} WhatsApp"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = Whatsapp::TokenExchangeService.new(@code).perform
|
||||
|
||||
# Fetch phone information
|
||||
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
|
||||
# Validate token has access to the WABA
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
|
||||
# Create channel
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
# Webhook setup is now handled in the channel after_create_commit callback
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded 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_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
missing_params << 'business_id' if @business_id.blank?
|
||||
missing_params << 'waba_id' if @waba_id.blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token(code)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/oauth/access_token",
|
||||
query: {
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: code
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token exchange failed')
|
||||
end
|
||||
|
||||
def fetch_phone_numbers(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
query: { access_token: @access_token }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
query: {
|
||||
input_token: input_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token validation failed')
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, pin)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
|
||||
headers: request_headers,
|
||||
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
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
|
||||
|
||||
def handle_response(response, error_message)
|
||||
raise "#{error_message}: #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::PhoneInfoService
|
||||
def initialize(waba_id, phone_number_id, access_token)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
fetch_and_process_phone_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def fetch_and_process_phone_info
|
||||
response = @api_client.fetch_phone_numbers(@waba_id)
|
||||
phone_numbers = response['data']
|
||||
|
||||
phone_data = find_phone_data(phone_numbers)
|
||||
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
|
||||
|
||||
build_phone_info(phone_data)
|
||||
end
|
||||
|
||||
def find_phone_data(phone_numbers)
|
||||
return nil if phone_numbers.blank?
|
||||
|
||||
if @phone_number_id.present?
|
||||
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
|
||||
else
|
||||
phone_numbers.first
|
||||
end
|
||||
end
|
||||
|
||||
def build_phone_info(phone_data)
|
||||
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 sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::TokenExchangeService
|
||||
def initialize(code)
|
||||
@code = code
|
||||
@api_client = Whatsapp::FacebookApiClient.new
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_code!
|
||||
exchange_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_code!
|
||||
raise ArgumentError, 'Authorization code is required' if @code.blank?
|
||||
end
|
||||
|
||||
def exchange_token
|
||||
response = @api_client.exchange_code_for_token(@code)
|
||||
access_token = response['access_token']
|
||||
|
||||
raise "No access token in response: #{response}" if access_token.blank?
|
||||
|
||||
access_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
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)
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
register_phone_number
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
pin = fetch_or_create_pin
|
||||
|
||||
@api_client.register_phone_number(phone_number_id, pin)
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
# Check if we have a stored PIN for this phone number
|
||||
existing_pin = @channel.provider_config['verification_pin']
|
||||
return existing_pin.to_i if existing_pin.present?
|
||||
|
||||
# Generate a new 6-digit PIN if none exists
|
||||
SecureRandom.random_number(900_000) + 100_000
|
||||
end
|
||||
|
||||
def store_pin(pin)
|
||||
# Store the PIN in provider_config for future use
|
||||
@channel.provider_config['verification_pin'] = pin
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -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'] %>
|
||||
|
||||
@@ -176,3 +176,6 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: whatsapp_embedded_signup
|
||||
display_name: WhatsApp Embedded Signup
|
||||
enabled: false
|
||||
|
||||
@@ -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'
|
||||
|
||||
+9
-1
@@ -63,7 +63,11 @@ 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]
|
||||
resources :documents, only: [:index, :show, :create, :destroy] do
|
||||
collection do
|
||||
post :upload_pdf
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
@@ -232,6 +236,10 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :whatsapp do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddPdfSupportToCaptainDocuments < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_documents, :source_type, :string, default: 'url'
|
||||
|
||||
add_index :captain_documents, :source_type
|
||||
end
|
||||
end
|
||||
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -289,9 +289,11 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "source_type", default: "url"
|
||||
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 ["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(
|
||||
params[:message_content],
|
||||
message_history
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
|
||||
render json: response
|
||||
|
||||
@@ -3,9 +3,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_documents, except: [:create]
|
||||
before_action :set_documents, except: [:create, :upload_pdf]
|
||||
before_action :set_document, only: [:show, :destroy]
|
||||
before_action :set_assistant, only: [:create]
|
||||
before_action :set_assistant, only: [:create, :upload_pdf]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
def index
|
||||
@@ -27,6 +28,23 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def upload_pdf
|
||||
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
|
||||
return render_could_not_create_error('No PDF file provided') if pdf_params[:pdf_document].blank?
|
||||
|
||||
@document = @assistant.documents.build(
|
||||
name: pdf_params[:pdf_document].original_filename,
|
||||
external_link: "pdf_upload_#{SecureRandom.hex(8)}.pdf",
|
||||
source_type: 'pdf_upload'
|
||||
)
|
||||
@document.file.attach(pdf_params[:pdf_document])
|
||||
@document.save!
|
||||
|
||||
render :create
|
||||
rescue Captain::Document::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@document.destroy
|
||||
head :no_content
|
||||
@@ -43,7 +61,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
|
||||
assistant_id = action_name == 'upload_pdf' ? pdf_params[:assistant_id] : document_params[:assistant_id]
|
||||
@assistant = Current.account.captain_assistants.find_by(id: assistant_id)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@@ -57,4 +76,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
def document_params
|
||||
params.require(:document).permit(:name, :external_link, :assistant_id)
|
||||
end
|
||||
end
|
||||
|
||||
def pdf_params
|
||||
params.permit(:pdf_document, :assistant_id)
|
||||
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,6 +1,7 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
|
||||
def perform(conversation, assistant)
|
||||
@conversation = conversation
|
||||
@@ -13,7 +14,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
generate_and_process_response
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
|
||||
|
||||
handle_error(e)
|
||||
ensure
|
||||
@@ -26,8 +27,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
@conversation.messages.incoming.last.content,
|
||||
collect_previous_messages
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
|
||||
return process_action('handoff') if handoff_requested?
|
||||
@@ -43,39 +43,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |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]
|
||||
{
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
end
|
||||
transcriptions
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
return 'system' if message.content.blank?
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
message.message_type == 'incoming' ? 'user' : 'system'
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def handoff_requested?
|
||||
|
||||
@@ -2,13 +2,21 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
if pdf_document?(document)
|
||||
Captain::Documents::PdfExtractionJob.perform_later(document)
|
||||
elsif 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?
|
||||
|
||||
document.source_type == 'pdf_upload'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
include Captain::FirecrawlHelper
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class Captain::Documents::PdfExtractionJob < ApplicationJob
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
return unless document.pdf_document?
|
||||
|
||||
initialize_document_processing(document)
|
||||
result = extract_pdf_content(document)
|
||||
process_extraction_result(document, result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_document_processing(document)
|
||||
document.update(status: 'in_progress')
|
||||
end
|
||||
|
||||
def extract_pdf_content(document)
|
||||
pdf_source = document.file.attached? ? document.file : document.external_link
|
||||
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(pdf_source)
|
||||
pdf_extraction_service.perform
|
||||
end
|
||||
|
||||
def process_extraction_result(document, result)
|
||||
return unless result[:success] && result[:content].present?
|
||||
|
||||
process_pdf_content_chunks(document, result[:content])
|
||||
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"
|
||||
|
||||
# Update main document with first page content only
|
||||
first_page_content = content_chunks.first&.dig(:content) || ''
|
||||
document.update!(
|
||||
content: first_page_content,
|
||||
status: 'available'
|
||||
)
|
||||
|
||||
# Reset previous responses once at the beginning to avoid race conditions
|
||||
document.responses.destroy_all
|
||||
|
||||
# Process each chunk separately but link FAQs to main document
|
||||
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
|
||||
|
||||
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
|
||||
end
|
||||
@@ -1,10 +1,17 @@
|
||||
class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
reset_previous_responses(document)
|
||||
def perform(document, full_content = nil, skip_reset: false)
|
||||
# Use full content for FAQ generation if provided (for PDFs), otherwise use document.content
|
||||
content_for_faqs = full_content || document.content
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
|
||||
# Skip processing if no content available
|
||||
return if content_for_faqs.blank?
|
||||
|
||||
# Only reset responses if not explicitly skipped (for PDF chunks processing)
|
||||
reset_previous_responses(document) unless skip_reset
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(content_for_faqs).generate
|
||||
faqs.each do |faq|
|
||||
create_response(faq, document)
|
||||
end
|
||||
@@ -24,6 +31,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
documentable: document
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Error in creating response document: #{e.message}"
|
||||
# Log validation errors but continue processing other FAQs
|
||||
Rails.logger.info "Skipping duplicate or invalid FAQ: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
class Captain::Tools::PdfExtractionParserJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(assistant_id:, pdf_content:, document_id: nil)
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
main_document = find_main_document(document_id)
|
||||
|
||||
return unless can_process_content?(pdf_content[:content], assistant.account, main_document)
|
||||
|
||||
enqueue_response_builder_job_for_chunk(main_document, pdf_content)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error "PDF parser job failed - Document not found: #{e.message}"
|
||||
rescue Captain::Document::LimitExceededError => e
|
||||
Rails.logger.info "PDF parser job stopped - Document limit exceeded: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_main_document(document_id)
|
||||
return unless document_id
|
||||
|
||||
Captain::Document.find(document_id)
|
||||
end
|
||||
|
||||
def can_process_content?(content, account, main_document)
|
||||
main_document && should_process_content?(content, account)
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job_for_chunk(main_document, pdf_content)
|
||||
# Create a context string for better AI processing that includes page info
|
||||
page_info = pdf_content[:page_number] ? " (Page #{pdf_content[:page_number]})" : ''
|
||||
chunk_info = pdf_content[:chunk_index] ? " Part #{pdf_content[:chunk_index]}" : ''
|
||||
|
||||
# Combine main document content with chunk content for comprehensive FAQ generation
|
||||
context_content = "#{main_document.content}\n\n--- Additional Content#{page_info}#{chunk_info} ---\n#{pdf_content[:content]}"
|
||||
|
||||
# Use the ResponseBuilderJob with full_content parameter to generate FAQs
|
||||
# This will link all FAQs to the main document while using the chunk content for AI processing
|
||||
# Skip reset since it's already done in the main PDF extraction job
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(main_document, context_content, skip_reset: true)
|
||||
end
|
||||
|
||||
def should_process_content?(content, account)
|
||||
content.present? && !limit_exceeded?(account)
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits.dig(:captain, :documents)
|
||||
limits && limits[:current_available].to_i <= 0
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@
|
||||
# content :text
|
||||
# external_link :string not null
|
||||
# name :string
|
||||
# source_type :string default("url")
|
||||
# status :integer default("in_progress"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -17,6 +18,7 @@
|
||||
# index_captain_documents_on_account_id (account_id)
|
||||
# index_captain_documents_on_assistant_id (assistant_id)
|
||||
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
|
||||
# index_captain_documents_on_source_type (source_type)
|
||||
# index_captain_documents_on_status (status)
|
||||
#
|
||||
class Captain::Document < ApplicationRecord
|
||||
@@ -26,11 +28,14 @@ class Captain::Document < ApplicationRecord
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
||||
belongs_to :account
|
||||
has_one_attached :file
|
||||
|
||||
validates :external_link, presence: true
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }
|
||||
validates :content, length: { maximum: 200_000 }
|
||||
validates :content, length: { maximum: 400_000 }
|
||||
validates :source_type, inclusion: { in: %w[url pdf_upload] }, allow_blank: true
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_default_source_type
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -47,6 +52,10 @@ class Captain::Document < ApplicationRecord
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
|
||||
def pdf_document?
|
||||
source_type == 'pdf_upload' || file.attached?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
@@ -57,6 +66,8 @@ 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
|
||||
@@ -73,4 +84,14 @@ 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?
|
||||
|
||||
self.source_type = if file.attached?
|
||||
'pdf_upload'
|
||||
else
|
||||
'url'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,4 +22,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
def playground?
|
||||
true
|
||||
end
|
||||
|
||||
def upload_pdf?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
register_tools
|
||||
end
|
||||
|
||||
def generate_response(input, previous_messages = [], role = 'user')
|
||||
@messages += previous_messages
|
||||
@messages << { role: role, content: input } if input.present?
|
||||
# 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?
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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,7 +1,7 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
def initialize
|
||||
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
|
||||
raise 'Missing API key' if @api_key.empty?
|
||||
raise 'Missing API key' if @api_key.blank?
|
||||
end
|
||||
|
||||
def perform(url, webhook_url, crawl_limit = 10)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
require 'pdf-reader'
|
||||
|
||||
class Captain::Tools::PdfExtractionService
|
||||
include ActiveModel::Validations
|
||||
include Captain::Tools::PdfValidationConcern
|
||||
include Captain::Tools::PdfContentChunkingConcern
|
||||
include Captain::Tools::PdfExtractionService::SourceHandler
|
||||
include Captain::Tools::PdfExtractionService::BlobHandler
|
||||
include Captain::Tools::PdfExtractionService::TextProcessor
|
||||
|
||||
class ExtractionError < StandardError; end
|
||||
|
||||
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]
|
||||
|
||||
process_pdf_extraction
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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 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 success_response(content)
|
||||
{
|
||||
success: true,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
|
||||
def failure_response(errors)
|
||||
{
|
||||
success: false,
|
||||
errors: errors
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
module Captain::Tools::PdfExtractionService::BlobHandler
|
||||
private
|
||||
|
||||
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
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
module Captain::Tools::PdfExtractionService::SourceHandler
|
||||
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)
|
||||
return :active_storage_attachment if pdf_source.is_a?(ActiveStorage::Attached::One)
|
||||
|
||||
:file_path
|
||||
end
|
||||
|
||||
def extract_text
|
||||
case determine_source_type
|
||||
when :url
|
||||
extract_from_url
|
||||
when :uploaded_file
|
||||
extract_from_uploaded_file
|
||||
when :active_storage_attachment
|
||||
extract_from_attachment
|
||||
else
|
||||
extract_from_file_path
|
||||
end
|
||||
end
|
||||
|
||||
def extract_from_url
|
||||
return extract_from_active_storage_blob if active_storage_blob_url?
|
||||
|
||||
temp_file = Down.download(
|
||||
pdf_source,
|
||||
max_size: self.class::MAX_PDF_SIZE,
|
||||
open_timeout: self.class::DOWNLOAD_TIMEOUT,
|
||||
read_timeout: self.class::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 extract_from_attachment
|
||||
return failure_response(['No file attached']) unless pdf_source.attached?
|
||||
|
||||
pdf_source.open { |file| extract_from_file(file.path) }
|
||||
end
|
||||
|
||||
def pdf_source_type
|
||||
case pdf_source
|
||||
when String
|
||||
pdf_source.start_with?('http') ? 'URL' : 'file_path'
|
||||
else
|
||||
'uploaded_file'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
module Captain::Tools::PdfExtractionService::TextProcessor
|
||||
private
|
||||
|
||||
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
|
||||
rescue PDF::Reader::MalformedPDFError => e
|
||||
Rails.logger.error "Malformed PDF error: #{e.message}"
|
||||
raise StandardError, 'Invalid or corrupted PDF file'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "PDF extraction error: #{e.message}"
|
||||
raise StandardError, "Failed to extract text from PDF: #{e.message}"
|
||||
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
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
module Captain::Tools::PdfValidationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def validate_pdf_source
|
||||
case determine_source_type
|
||||
when :url
|
||||
validate_url_format
|
||||
when :uploaded_file
|
||||
validate_file_type_and_size
|
||||
when :active_storage_attachment
|
||||
validate_attachment
|
||||
end
|
||||
|
||||
{ success: true }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "PDF validation failed: #{e.message}"
|
||||
{ success: false, errors: [e.message] }
|
||||
end
|
||||
|
||||
def validate_url_format
|
||||
URI.parse(pdf_source)
|
||||
end
|
||||
|
||||
def validate_file_type_and_size
|
||||
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
|
||||
raise StandardError, 'File too large' if pdf_source.size > 25.megabytes
|
||||
end
|
||||
|
||||
def validate_attachment
|
||||
raise StandardError, 'No file attached' unless pdf_source.attached?
|
||||
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
|
||||
end
|
||||
end
|
||||
@@ -4,10 +4,29 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
return unless should_process_captain_response?
|
||||
return perform_handoff unless inbox.captain_active?
|
||||
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(
|
||||
conversation,
|
||||
conversation.inbox.captain_assistant
|
||||
)
|
||||
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
|
||||
end
|
||||
|
||||
def should_process_captain_response?
|
||||
|
||||
@@ -8,4 +8,15 @@ json.external_link resource.external_link
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.status resource.status
|
||||
json.source_type resource.source_type
|
||||
json.updated_at resource.updated_at.to_i
|
||||
|
||||
# Include file information for PDF uploads
|
||||
if resource.file.attached?
|
||||
json.file do
|
||||
json.url url_for(resource.file)
|
||||
json.filename resource.file.filename
|
||||
json.content_type resource.file.content_type
|
||||
json.size resource.file.byte_size
|
||||
end
|
||||
end
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Script to get business account path for a specific inbox ID
|
||||
# Usage: ruby get_business_account_path.rb
|
||||
|
||||
# Set the inbox ID to query
|
||||
INBOX_ID = 1090
|
||||
|
||||
# Load Rails environment (assuming this script is run from the Rails app root)
|
||||
require_relative 'config/environment'
|
||||
|
||||
def get_business_account_path(inbox_id)
|
||||
# Find the inbox by ID
|
||||
inbox = Inbox.find(inbox_id)
|
||||
|
||||
# Check if this is a WhatsApp inbox
|
||||
unless inbox.whatsapp?
|
||||
puts "Error: Inbox #{inbox_id} is not a WhatsApp inbox. Channel type: #{inbox.channel_type}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Get the WhatsApp channel
|
||||
whatsapp_channel = inbox.channel
|
||||
|
||||
# Check if it's a WhatsApp Cloud provider
|
||||
unless whatsapp_channel.provider == 'whatsapp_cloud'
|
||||
puts "Error: Inbox #{inbox_id} is not using WhatsApp Cloud provider. Provider: #{whatsapp_channel.provider}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Get the business_account_id and api_key from provider_config
|
||||
business_account_id = whatsapp_channel.provider_config['business_account_id']
|
||||
api_key = whatsapp_channel.provider_config['api_key']
|
||||
|
||||
if business_account_id.blank?
|
||||
puts "Error: No business_account_id found in provider_config for inbox #{inbox_id}"
|
||||
return nil
|
||||
end
|
||||
|
||||
if api_key.blank?
|
||||
puts "Error: No api_key (access token) found in provider_config for inbox #{inbox_id}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Construct the business account path (following the pattern from WhatsappCloudService)
|
||||
api_base_path = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
business_account_path = "#{api_base_path}/v14.0/#{business_account_id}"
|
||||
|
||||
return {
|
||||
inbox_id: inbox_id,
|
||||
inbox_name: inbox.name,
|
||||
business_account_id: business_account_id,
|
||||
business_account_path: business_account_path,
|
||||
provider: whatsapp_channel.provider,
|
||||
phone_number: whatsapp_channel.phone_number,
|
||||
api_key: api_key,
|
||||
access_token: api_key # alias for clarity
|
||||
}
|
||||
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
puts "Error: Inbox with ID #{inbox_id} not found"
|
||||
return nil
|
||||
rescue StandardError => e
|
||||
puts "Error: #{e.message}"
|
||||
puts e.backtrace.first(5).join("\n") if ENV['DEBUG']
|
||||
return nil
|
||||
end
|
||||
|
||||
# Main execution
|
||||
puts "Getting business account path for inbox ID: #{INBOX_ID}"
|
||||
puts '=' * 50
|
||||
|
||||
result = get_business_account_path(INBOX_ID)
|
||||
|
||||
if result
|
||||
puts 'Success! Found WhatsApp Business Account details:'
|
||||
puts
|
||||
puts "Inbox ID: #{result[:inbox_id]}"
|
||||
puts "Inbox Name: #{result[:inbox_name]}"
|
||||
puts "Phone Number: #{result[:phone_number]}"
|
||||
puts "Provider: #{result[:provider]}"
|
||||
puts "Business Account ID: #{result[:business_account_id]}"
|
||||
puts
|
||||
puts 'Business Account Path:'
|
||||
puts result[:business_account_path]
|
||||
puts
|
||||
puts 'Access Token (API Key):'
|
||||
puts result[:access_token]
|
||||
puts
|
||||
puts 'Complete API Endpoint Examples:'
|
||||
puts "• Message Templates: #{result[:business_account_path]}/message_templates?access_token=#{result[:access_token]}"
|
||||
puts "• Phone Numbers: #{result[:business_account_path]}/phone_numbers?access_token=#{result[:access_token]}"
|
||||
puts
|
||||
puts 'Authorization Header Format:'
|
||||
puts "Authorization: Bearer #{result[:access_token]}"
|
||||
else
|
||||
puts "Failed to get business account path for inbox ID #{INBOX_ID}"
|
||||
exit 1
|
||||
end
|
||||
@@ -0,0 +1,279 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Accounts::Whatsapp::AuthorizationsController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'POST #create' do
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post :create, params: { account_id: account.id }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
before { sign_in(agent) }
|
||||
|
||||
context 'when feature is not enabled' do
|
||||
before do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature is enabled' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when code is missing' do
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('code')
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when business_id is missing' do
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('business_id')
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when waba_id is missing' do
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('waba_id')
|
||||
end
|
||||
|
||||
it 'creates whatsapp channel successfully' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service to prevent HTTP calls
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['success']).to be true
|
||||
expect(response_data['id']).to eq(inbox.id)
|
||||
expect(response_data['name']).to eq(inbox.name)
|
||||
expect(response_data['channel_type']).to eq('whatsapp')
|
||||
end
|
||||
|
||||
it 'calls the embedded signup service with correct parameters' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
).and_return(embedded_signup_service)
|
||||
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
}
|
||||
end
|
||||
|
||||
it 'accepts phone_number_id as optional parameter' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: nil
|
||||
).and_return(embedded_signup_service)
|
||||
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when service fails' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['success']).to be false
|
||||
expect(response_data['error']).to eq('Service error')
|
||||
end
|
||||
|
||||
it 'logs error when service fails' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(/\[WHATSAPP AUTHORIZATION\] Embedded signup error: Service error/)
|
||||
expect(Rails.logger).to receive(:error).with(/authorizations_controller/)
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
end
|
||||
|
||||
it 'handles token exchange errors' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new)
|
||||
.and_raise(StandardError, 'Invalid authorization code')
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'invalid_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid authorization code')
|
||||
end
|
||||
|
||||
it 'handles channel already exists error' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new)
|
||||
.and_raise(StandardError, 'Channel already exists')
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Channel already exists')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authorized for the account' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post :create, params: {
|
||||
account_id: other_account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
sign_in(administrator)
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'allows channel creation' do
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post :create, params: {
|
||||
account_id: account.id,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
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(
|
||||
valid_params[:message_content],
|
||||
valid_params[:message_history]
|
||||
additional_message: valid_params[:message_content],
|
||||
message_history: 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(
|
||||
params_without_history[:message_content],
|
||||
[]
|
||||
additional_message: params_without_history[:message_content],
|
||||
message_history: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -220,9 +220,17 @@ 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
|
||||
# First delete any existing config to avoid conflicts
|
||||
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').destroy_all
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
|
||||
account.reload # Reload to ensure changes are reflected
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
@@ -288,4 +296,69 @@ 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[:name]).to be_present
|
||||
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,5 +30,142 @@ 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
|
||||
|
||||
@@ -14,7 +14,20 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
before do
|
||||
allow(Captain::Tools::FirecrawlService).to receive(:new).and_return(firecrawl_service)
|
||||
allow(firecrawl_service).to receive(:perform)
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test-key')
|
||||
|
||||
# Make sure we have the Firecrawl config properly set
|
||||
config = InstallationConfig.find_or_create_by(name: 'CAPTAIN_FIRECRAWL_API_KEY') do |config|
|
||||
config.value = 'test-key'
|
||||
end
|
||||
if config.value != 'test-key'
|
||||
config.value = 'test-key'
|
||||
config.save!
|
||||
end
|
||||
|
||||
# Mock simple crawl service to avoid HTTP calls if it somehow gets called
|
||||
simple_crawler = instance_double(Captain::Tools::SimplePageCrawlService)
|
||||
allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(simple_crawler)
|
||||
allow(simple_crawler).to receive(:page_links).and_return([])
|
||||
end
|
||||
|
||||
context 'with account usage limits' do
|
||||
@@ -73,7 +86,6 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
before do
|
||||
allow(Captain::Tools::SimplePageCrawlService)
|
||||
.to receive(:new)
|
||||
.with(document.external_link)
|
||||
.and_return(simple_crawler)
|
||||
|
||||
allow(simple_crawler).to receive(:page_links).and_return(page_links)
|
||||
@@ -104,6 +116,32 @@ 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', source_type: 'pdf_upload') }
|
||||
|
||||
it 'delegates to PDFExtractionJob' do
|
||||
expect(Captain::Documents::PdfExtractionJob)
|
||||
.to receive(:perform_later)
|
||||
.with(pdf_document)
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#pdf_document?' do
|
||||
let(:job) { described_class.new }
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::PdfExtractionJob, type: :job do
|
||||
let(:document) { create(:captain_document, external_link: 'https://example.com/document.pdf', source_type: 'pdf_upload') }
|
||||
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: 2 },
|
||||
{ content: 'PDF page 2 content', page_number: 2, chunk_index: 2, total_chunks: 2 }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Tools::PdfExtractionService)
|
||||
.to receive(:new)
|
||||
.and_return(pdf_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when document is not a PDF' do
|
||||
let(:web_document) { create(:captain_document, external_link: 'https://example.com/page.html') }
|
||||
|
||||
it 'returns early without processing' do
|
||||
expect(pdf_service).not_to receive(:perform)
|
||||
described_class.perform_now(web_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document is a PDF' do
|
||||
it 'updates document status to in_progress' do
|
||||
allow(pdf_service).to receive(:perform).and_return({ success: true, content: pdf_content })
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(document).to receive(:update).with(status: 'in_progress').once
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'uses correct PDF source for extraction' do
|
||||
allow(pdf_service).to receive(:perform).and_return({ success: true, content: pdf_content })
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(Captain::Tools::PdfExtractionService)
|
||||
.to receive(:new)
|
||||
.with(document.external_link)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
context 'when document has attached file' do
|
||||
let(:attached_file) { instance_double(ActiveStorage::Attached::One, attached?: true) }
|
||||
|
||||
before do
|
||||
allow(document).to receive(:file).and_return(attached_file)
|
||||
end
|
||||
|
||||
it 'uses attached file for extraction' do
|
||||
allow(pdf_service).to receive(:perform).and_return({ success: true, content: pdf_content })
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(Captain::Tools::PdfExtractionService)
|
||||
.to receive(:new)
|
||||
.with(document.file)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when extraction succeeds' do
|
||||
before do
|
||||
allow(pdf_service).to receive(:perform).and_return({ success: true, content: pdf_content })
|
||||
end
|
||||
|
||||
it 'updates document with first page content only' do
|
||||
expect(document).to receive(:update!).with(
|
||||
content: 'PDF page 1 content',
|
||||
status: 'available'
|
||||
)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'resets previous responses once at the beginning' do
|
||||
allow(document).to receive(:update!)
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(document.responses).to receive(:destroy_all).once
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'enqueues PdfExtractionParserJob for each content chunk' do
|
||||
allow(document).to receive(:update!)
|
||||
|
||||
pdf_content.each do |chunk|
|
||||
expect(Captain::Tools::PdfExtractionParserJob)
|
||||
.to receive(:perform_later)
|
||||
.with(
|
||||
assistant_id: document.assistant_id,
|
||||
pdf_content: chunk,
|
||||
document_id: document.id
|
||||
)
|
||||
end
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'updates document status to in_progress initially' do
|
||||
allow(document).to receive(:update!)
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(document).to receive(:update).with(status: 'in_progress').once
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'logs successful extraction' do
|
||||
allow(document).to receive(:update!)
|
||||
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with(/PDF extraction successful/).at_least(:once)
|
||||
expect(Rails.logger).to receive(:info).with(/chunks queued/).at_least(:once)
|
||||
allow(Rails.logger).to receive(:info) # Allow other logging calls
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when extraction fails' do
|
||||
before do
|
||||
allow(pdf_service).to receive(:perform).and_return({
|
||||
success: false,
|
||||
errors: ['Invalid PDF format', 'File corrupted']
|
||||
})
|
||||
end
|
||||
|
||||
it 'updates document status to available' do
|
||||
expect(document).to receive(:update).with(status: 'in_progress')
|
||||
expect(document).not_to receive(:update).with(status: 'available')
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).not_to receive(:error)
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when extraction raises an exception' do
|
||||
before do
|
||||
allow(pdf_service).to receive(:perform).and_raise(Captain::Tools::PdfExtractionService::ExtractionError, 'Network error')
|
||||
end
|
||||
|
||||
it 'updates document status to available' do
|
||||
expect(document).to receive(:update).with(status: 'in_progress')
|
||||
expect(document).not_to receive(:update).with(status: 'available')
|
||||
|
||||
expect { described_class.perform_now(document) }.to raise_error(Captain::Tools::PdfExtractionService::ExtractionError)
|
||||
end
|
||||
|
||||
it 'logs the exception' do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
expect { described_class.perform_now(document) }.to raise_error(Captain::Tools::PdfExtractionService::ExtractionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
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!(:main_document) { create(:captain_document, assistant: assistant, account: account, content: 'Main document content') }
|
||||
let(:pdf_content) do
|
||||
{
|
||||
content: 'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.',
|
||||
page_number: 2,
|
||||
chunk_index: 1,
|
||||
total_chunks: 1
|
||||
}
|
||||
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 document_id is provided and document exists' do
|
||||
it 'does not create new documents' do
|
||||
expect do
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: main_document.id
|
||||
)
|
||||
end.not_to change(Captain::Document, :count)
|
||||
end
|
||||
|
||||
it 'enqueues ResponseBuilderJob with combined content for the main document' do
|
||||
expected_content = "Main document content\n\n--- Additional Content (Page 2) Part 1 ---\n" \
|
||||
'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.'
|
||||
|
||||
expect(Captain::Documents::ResponseBuilderJob)
|
||||
.to receive(:perform_later)
|
||||
.with(main_document, expected_content, skip_reset: true)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: main_document.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'combines main document content with chunk content' do
|
||||
chunk_content = pdf_content.merge(page_number: 3, chunk_index: 2)
|
||||
expected_content = "Main document content\n\n--- Additional Content (Page 3) Part 2 ---\n" \
|
||||
'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.'
|
||||
|
||||
expect(Captain::Documents::ResponseBuilderJob)
|
||||
.to receive(:perform_later)
|
||||
.with(main_document, expected_content, skip_reset: true)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: chunk_content,
|
||||
document_id: main_document.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document_id is nil' do
|
||||
it 'returns early without processing' do
|
||||
expect(Captain::Documents::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document_id is provided but document does not exist' do
|
||||
it 'logs error and returns without processing' do
|
||||
expect(Rails.logger).to receive(:error).with(/Document not found/)
|
||||
expect(Captain::Documents::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: 99_999
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assistant is not found' do
|
||||
it 'logs error and returns without processing' do
|
||||
expect(Rails.logger).to receive(:error).with(/Document not found/)
|
||||
expect(Captain::Documents::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: 99_999,
|
||||
pdf_content: pdf_content,
|
||||
document_id: main_document.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when limits are exceeded' do
|
||||
before do
|
||||
# Set up account limits configuration to exceed limits
|
||||
captain_limits = {
|
||||
'startups' => {
|
||||
'captain_documents' => 1,
|
||||
'captain_responses' => 100
|
||||
}
|
||||
}
|
||||
|
||||
# First delete any existing config to avoid conflicts
|
||||
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').destroy_all
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
|
||||
# Create more documents than the limit to exceed it
|
||||
create_list(:captain_document, 5, assistant: assistant, account: account, status: :available)
|
||||
account.update_document_usage
|
||||
account.reload
|
||||
end
|
||||
|
||||
it 'does not process content when limit exceeded' do
|
||||
expect(Captain::Documents::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform(
|
||||
assistant_id: assistant.id,
|
||||
pdf_content: pdf_content,
|
||||
document_id: main_document.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
let(:job) { described_class.new }
|
||||
|
||||
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
|
||||
@@ -39,12 +39,19 @@ RSpec.describe Account, type: :model do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'ACCOUNT_AGENTS_LIMIT', value: 20)
|
||||
config = InstallationConfig.find_or_create_by(name: 'ACCOUNT_AGENTS_LIMIT') do |config|
|
||||
config.value = 20
|
||||
end
|
||||
if config.value != 20
|
||||
config.value = 20
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when captain limits are configured' do
|
||||
before do
|
||||
create_list(:captain_document, 3, account: account, assistant: assistant, status: :available)
|
||||
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').destroy_all
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
end
|
||||
|
||||
@@ -131,6 +138,7 @@ RSpec.describe Account, type: :model do
|
||||
|
||||
describe 'when limits are configured for an account' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').destroy_all
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
account.update(limits: { captain_documents: 5555, captain_responses: 9999 })
|
||||
end
|
||||
@@ -175,7 +183,9 @@ RSpec.describe Account, type: :model do
|
||||
|
||||
it 'returns max limits from app limit if account limit and installation config is absent' do
|
||||
account.update(limits: { agents: '' })
|
||||
InstallationConfig.where(name: 'ACCOUNT_AGENTS_LIMIT').update(value: '')
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_AGENTS_LIMIT')
|
||||
config.value = '' if config
|
||||
config&.save!
|
||||
|
||||
expect(account.usage_limits[:agents]).to eq(ChatwootApp.max_limit)
|
||||
end
|
||||
@@ -191,7 +201,13 @@ RSpec.describe Account, type: :model do
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLAN_FEATURES').first_or_create(value: plan_features)
|
||||
config = InstallationConfig.find_or_create_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES') do |config|
|
||||
config.value = plan_features
|
||||
end
|
||||
if config.value != plan_features
|
||||
config.value = plan_features
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plan_name is hacker' do
|
||||
|
||||
@@ -21,7 +21,9 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |config|
|
||||
config.value = 'test-key'
|
||||
end
|
||||
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
|
||||
allow(mock_openai_client).to receive(:chat).and_return({
|
||||
choices: [{ message: { content: '{ "content": "Hey" }' } }]
|
||||
|
||||
@@ -8,7 +8,9 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||
|
||||
before do
|
||||
create(:installation_config) { create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') }
|
||||
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |config|
|
||||
config.value = 'test-key'
|
||||
end
|
||||
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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
|
||||
@@ -7,7 +7,13 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
let(:crawl_limit) { 15 }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
config = InstallationConfig.find_or_create_by(name: 'CAPTAIN_FIRECRAWL_API_KEY') do |config|
|
||||
config.value = api_key
|
||||
end
|
||||
if config.value != api_key
|
||||
config.value = api_key
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
@@ -29,17 +35,21 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when API key is nil' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil)
|
||||
config = InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')
|
||||
config.value = nil
|
||||
config.save!
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new }.to raise_error(NoMethodError)
|
||||
expect { described_class.new }.to raise_error('Missing API key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API key is empty' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '')
|
||||
config = InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')
|
||||
config.value = ''
|
||||
config.save!
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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/valid_test.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 and returns error for invalid files' do
|
||||
expect { service.perform }.to raise_error(StandardError, 'Invalid or corrupted PDF file')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed PDF' do
|
||||
let(:pdf_source) { sample_pdf_path.to_s }
|
||||
|
||||
it 'handles malformed PDF gracefully' do
|
||||
# Mock PDF::Reader to raise a MalformedPDFError
|
||||
allow(PDF::Reader).to receive(:open).and_raise(PDF::Reader::MalformedPDFError, 'PDF does not contain EOF marker')
|
||||
|
||||
expect { service.perform }.to raise_error(StandardError, 'Invalid or corrupted PDF file')
|
||||
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)
|
||||
|
||||
# Mock PDF reader to return sample content
|
||||
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
|
||||
|
||||
# The test should handle both success and controlled failures gracefully
|
||||
expect(result).to have_key(:success)
|
||||
if result[:success]
|
||||
expect(result[:content]).to be_an(Array)
|
||||
else
|
||||
expect(result[:errors]).to be_an(Array)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_text (private method)' 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 through perform method' do
|
||||
expect { service.perform }.to raise_error(StandardError, 'Invalid or corrupted PDF file')
|
||||
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_file_type_and_size) }.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_file_type_and_size) }.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_file_type_and_size) }.to raise_error('Invalid file type')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
%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
@@ -0,0 +1,2 @@
|
||||
This is a sample text file for testing purposes.
|
||||
It contains some basic text content that can be used in tests.
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
%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
|
||||
@@ -61,4 +61,85 @@ RSpec.describe Channel::Whatsapp do
|
||||
expect(channel.provider_config['webhook_verify_token']).to eq '123'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'webhook setup after creation' do
|
||||
let(:account) { create(:account) }
|
||||
let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when channel is created through embedded signup' do
|
||||
it 'sets up webhooks automatically' do
|
||||
expect(Whatsapp::WebhookSetupService).to receive(:new).with(
|
||||
anything,
|
||||
'test_waba_id',
|
||||
'test_access_token'
|
||||
)
|
||||
expect(webhook_service).to receive(:perform)
|
||||
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end
|
||||
|
||||
it 'does not raise error if webhook setup fails' do
|
||||
allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
|
||||
|
||||
expect do
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is created through manual setup' do
|
||||
it 'does not setup webhooks' do
|
||||
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
||||
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is created with different provider' do
|
||||
it 'does not setup webhooks for 360dialog provider' do
|
||||
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
||||
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'default',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'api_key' => 'test_360dialog_key'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Featurable do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'WhatsApp embedded signup feature' do
|
||||
it 'is disabled by default' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
|
||||
end
|
||||
|
||||
describe '#enable_features!' do
|
||||
it 'enables the whatsapp embedded signup feature' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
|
||||
end
|
||||
|
||||
it 'enables multiple features at once' do
|
||||
account.enable_features!(:whatsapp_embedded_signup, :help_center)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_help_center?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_features!' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'disables the whatsapp embedded signup feature' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enabled_features' do
|
||||
it 'includes whatsapp_embedded_signup when enabled' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
|
||||
it 'does not include whatsapp_embedded_signup when disabled' do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#all_features' do
|
||||
it 'includes whatsapp_embedded_signup in all features list' do
|
||||
expect(account.all_features).to have_key('whatsapp_embedded_signup')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::ChannelCreationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
|
||||
# Stub the webhook setup service to prevent HTTP calls during tests
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
# Stub the provider validation and sync_templates
|
||||
allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
|
||||
channel = method.call(*args)
|
||||
allow(channel).to receive(:validate_provider_config)
|
||||
allow(channel).to receive(:sync_templates)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel does not exist' do
|
||||
it 'creates a new channel' do
|
||||
expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates channel with correct attributes' do
|
||||
channel = service.perform
|
||||
expect(channel.phone_number).to eq('+1234567890')
|
||||
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('test_phone_id')
|
||||
expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
|
||||
expect(channel.provider_config['source']).to eq('embedded_signup')
|
||||
end
|
||||
|
||||
it 'creates an inbox for the channel' do
|
||||
channel = service.perform
|
||||
inbox = channel.inbox
|
||||
expect(inbox).not_to be_nil
|
||||
expect(inbox.name).to eq('Test Business WhatsApp')
|
||||
expect(inbox.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel already exists' do
|
||||
before do
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Channel already exists/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when account is nil' do
|
||||
service = described_class.new(nil, waba_info, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
|
||||
end
|
||||
|
||||
it 'raises error when waba_info is nil' do
|
||||
service = described_class.new(account, nil, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
|
||||
end
|
||||
|
||||
it 'raises error when phone_info is nil' do
|
||||
service = described_class.new(account, waba_info, nil, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
|
||||
end
|
||||
|
||||
it 'raises error when access_token is blank' do
|
||||
service = described_class.new(account, waba_info, phone_info, '')
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is in different places' do
|
||||
context 'when business_name is only in phone_info' do
|
||||
let(:waba_info) { { waba_id: 'test_waba_id' } }
|
||||
|
||||
it 'uses business_name from phone_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is only in waba_info' do
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true
|
||||
}
|
||||
end
|
||||
|
||||
it 'uses business_name from waba_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
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(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:channel) { instance_double(Channel::Whatsapp) }
|
||||
|
||||
let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
|
||||
let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
|
||||
let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
|
||||
let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
|
||||
allow(token_exchange_service).to receive(:perform).and_return(access_token)
|
||||
|
||||
allow(Whatsapp::PhoneInfoService).to receive(:new)
|
||||
.with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
|
||||
allow(phone_info_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, waba_id).and_return(token_validation_service)
|
||||
allow(token_validation_service).to receive(:perform)
|
||||
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
|
||||
.and_return(channel_creation_service)
|
||||
allow(channel_creation_service).to receive(:perform).and_return(channel)
|
||||
|
||||
# Webhook setup is now handled in the channel after_create callback
|
||||
# So we stub it at the channel level
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
end
|
||||
|
||||
it 'orchestrates all services in the correct order' do
|
||||
expect(GlobalConfig).to receive(:clear_cache)
|
||||
expect(token_exchange_service).to receive(:perform).ordered
|
||||
expect(phone_info_service).to receive(:perform).ordered
|
||||
expect(token_validation_service).to receive(:perform).ordered
|
||||
expect(channel_creation_service).to receive(:perform).ordered
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when code is blank' 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, /Required parameters are missing: code/)
|
||||
end
|
||||
|
||||
it 'raises error when business_id is blank' 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, /Required parameters are missing: business_id/)
|
||||
end
|
||||
|
||||
it 'raises error when waba_id is blank' 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, /Required parameters are missing: waba_id/)
|
||||
end
|
||||
|
||||
it 'raises error when multiple parameters are blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: '',
|
||||
business_id: '',
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when any service fails' do
|
||||
it 'logs and re-raises the error' do
|
||||
allow(token_exchange_service).to receive(:perform).and_raise('Token error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
|
||||
expect { service.perform }.to raise_error('Token error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::FacebookApiClient do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:api_client) { described_class.new(access_token) }
|
||||
let(:api_version) { 'v22.0' }
|
||||
let(:app_id) { 'test_app_id' }
|
||||
let(:app_secret) { 'test_app_secret' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
|
||||
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)
|
||||
end
|
||||
|
||||
describe '#exchange_code_for_token' do
|
||||
let(:code) { 'test_code' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: { client_id: app_id, client_secret: app_secret, code: code })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: 'new_token' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the response data' do
|
||||
result = api_client.exchange_code_for_token(code)
|
||||
expect(result['access_token']).to eq('new_token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: { 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 { api_client.exchange_code_for_token(code) }.to raise_error(/Token exchange failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_phone_numbers' do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: { access_token: access_token })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [{ id: '123', display_phone_number: '1234567890' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the phone numbers data' do
|
||||
result = api_client.fetch_phone_numbers(waba_id)
|
||||
expect(result['data']).to be_an(Array)
|
||||
expect(result['data'].first['id']).to eq('123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: { access_token: access_token })
|
||||
.to_return(status: 403, body: { error: 'Access denied' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.fetch_phone_numbers(waba_id) }.to raise_error(/WABA phone numbers fetch failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#debug_token' do
|
||||
let(:input_token) { 'test_input_token' }
|
||||
let(:app_access_token) { "#{app_id}|#{app_secret}" }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: { input_token: input_token, access_token: app_access_token })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: { app_id: app_id, is_valid: true } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the debug token data' do
|
||||
result = api_client.debug_token(input_token)
|
||||
expect(result['data']['is_valid']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: { input_token: input_token, access_token: app_access_token })
|
||||
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.debug_token(input_token) }.to raise_error(/Token validation failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#register_phone_number' do
|
||||
let(:phone_number_id) { 'test_phone_id' }
|
||||
let(:pin) { '123456' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: pin }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
result = api_client.register_phone_number(phone_number_id, pin)
|
||||
expect(result['success']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: pin }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Registration failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.register_phone_number(phone_number_id, pin) }.to raise_error(/Phone registration failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#subscribe_waba_webhook' do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:callback_url) { 'https://example.com/webhook' }
|
||||
let(:verify_token) { 'test_verify_token' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
expect(result['success']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,147 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::PhoneInfoService do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:phone_number_id) { 'test_phone_number_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(waba_id, phone_number_id, access_token) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => phone_number_id,
|
||||
'display_phone_number' => '1234567890',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when all parameters are valid' do
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'returns formatted phone info' do
|
||||
result = service.perform
|
||||
expect(result).to eq({
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone_number_id is not provided' do
|
||||
let(:phone_number_id) { nil }
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => 'first_phone_id',
|
||||
'display_phone_number' => '1234567890',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number_id]).to eq('first_phone_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when specific phone_number_id is not found' do
|
||||
let(:phone_number_id) { 'different_id' }
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => 'available_phone_id',
|
||||
'display_phone_number' => '9876543210',
|
||||
'verified_name' => 'Different Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number as fallback' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number_id]).to eq('available_phone_id')
|
||||
expect(result[:phone_number]).to eq('+9876543210')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no phone numbers are available' do
|
||||
let(:phone_response) { { 'data' => [] } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/No phone numbers found for WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone number has special characters' do
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => phone_number_id,
|
||||
'display_phone_number' => '+1 (234) 567-8900',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'sanitizes the phone number' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number]).to eq('+12345678900')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenExchangeService do
|
||||
let(:code) { 'test_authorization_code' }
|
||||
let(:service) { described_class.new(code) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when code is valid' do
|
||||
let(:token_response) { { 'access_token' => 'new_access_token' } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
|
||||
end
|
||||
|
||||
it 'returns the access token' do
|
||||
expect(service.perform).to eq('new_access_token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when code is blank' do
|
||||
let(:service) { described_class.new('') }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Authorization code is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response has no access token' do
|
||||
let(:token_response) { { 'error' => 'Invalid code' } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/No access token in response/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenValidationService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:service) { described_class.new(access_token, waba_id) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when token has access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => [waba_id, 'another_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'validates successfully' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'some_other_scope',
|
||||
'target_ids' => ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error('No WABA scope found in token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::WebhookSetupService do
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp,
|
||||
phone_number: '+1234567890',
|
||||
provider_config: {
|
||||
'phone_number_id' => 'test_phone_id',
|
||||
'webhook_verify_token' => 'test_verify_token'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
end
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(channel, waba_id, access_token) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when all operations succeed' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token')
|
||||
.and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'registers the phone number' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets up webhook subscription' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone registration fails' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
.and_raise('Registration failed')
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'continues with webhook setup' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup fails' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.and_raise('Webhook failed')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect { service.perform }.to raise_error(/Webhook setup failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when channel is nil' do
|
||||
service = described_class.new(nil, waba_id, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Channel is required')
|
||||
end
|
||||
|
||||
it 'raises error when waba_id is blank' do
|
||||
service = described_class.new(channel, '', access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
|
||||
it 'raises error when access_token is blank' do
|
||||
service = described_class.new(channel, waba_id, '')
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when PIN already exists' do
|
||||
before do
|
||||
channel.provider_config['verification_pin'] = 123_456
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'reuses existing PIN' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 123_456)
|
||||
expect(SecureRandom).not_to receive(:random_number)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user