Merge branch 'assignment_v2/migrations' into wip/assignment-import
This commit is contained in:
@@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_record_invalid(e)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
# Handles the embedded signup callback data from the Facebook SDK
|
||||
# Handles both initial authorization and reauthorization
|
||||
# If inbox_id is present in params, it performs reauthorization
|
||||
def create
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
@@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
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]
|
||||
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
|
||||
inbox_id: params[:inbox_id]
|
||||
)
|
||||
service.perform
|
||||
end
|
||||
|
||||
def render_success_response(inbox)
|
||||
def fetch_and_validate_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
validate_reauthorization_required
|
||||
end
|
||||
|
||||
def validate_reauthorization_required
|
||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
message: I18n.t('inbox.reauthorization.not_required')
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def can_upgrade_to_embedded_signup?
|
||||
channel = @inbox.channel
|
||||
return false unless channel.provider == 'whatsapp_cloud'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def render_success_response(inbox)
|
||||
response = {
|
||||
success: true,
|
||||
id: inbox.id,
|
||||
name: inbox.name,
|
||||
channel_type: 'whatsapp'
|
||||
}
|
||||
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
|
||||
render json: response
|
||||
end
|
||||
|
||||
def render_error_response(error)
|
||||
|
||||
@@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController
|
||||
:NumMedia,
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType
|
||||
:MessageType,
|
||||
:ProfileName
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,13 @@ class NotificationFinder
|
||||
end
|
||||
|
||||
def unread_count
|
||||
@notifications.where(read_at: nil).count
|
||||
if type_included?('read')
|
||||
# If we're including read notifications, filter to unread
|
||||
@notifications.where(read_at: nil).count
|
||||
else
|
||||
# Already filtered to unread notifications, just count
|
||||
@notifications.count
|
||||
end
|
||||
end
|
||||
|
||||
def count
|
||||
@@ -27,7 +33,7 @@ class NotificationFinder
|
||||
def set_up
|
||||
find_all_notifications
|
||||
filter_snoozed_notifications
|
||||
fitler_read_notifications
|
||||
filter_read_notifications
|
||||
end
|
||||
|
||||
def find_all_notifications
|
||||
@@ -38,7 +44,7 @@ class NotificationFinder
|
||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||
end
|
||||
|
||||
def fitler_read_notifications
|
||||
def filter_read_notifications
|
||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient {
|
||||
createEmbeddedSignup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||
}
|
||||
|
||||
reauthorizeWhatsApp({ inboxId, ...params }) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
|
||||
...params,
|
||||
inbox_id: inboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappChannel();
|
||||
|
||||
@@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
|
||||
+3
-3
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
||||
import { required, minLength, helpers, url } from '@vuelidate/validators';
|
||||
import { isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -71,7 +71,7 @@ const rules = {
|
||||
isValidSlug
|
||||
),
|
||||
},
|
||||
homePageLink: { shouldBeUrl },
|
||||
homePageLink: { url },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -33,10 +34,18 @@ const props = defineProps({
|
||||
validator: value =>
|
||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hideOfflineStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload', 'delete']);
|
||||
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
|
||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||
};
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
const STATUS_CLASSES = computed(() => ({
|
||||
online: 'bg-n-teal-10',
|
||||
busy: 'bg-n-amber-10',
|
||||
offline: 'bg-n-slate-10',
|
||||
};
|
||||
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
|
||||
}));
|
||||
|
||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||
|
||||
@@ -178,11 +187,18 @@ watch(
|
||||
<!-- Status Badge -->
|
||||
<slot name="badge" :size="size">
|
||||
<div
|
||||
v-if="status"
|
||||
v-if="status && STATUS_CLASSES[status]"
|
||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||
:style="badgeStyles"
|
||||
:class="STATUS_CLASSES[status]"
|
||||
/>
|
||||
<div
|
||||
v-if="inbox && !(status && STATUS_CLASSES[status])"
|
||||
:style="badgeStyles"
|
||||
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
|
||||
>
|
||||
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- Delete Avatar Button -->
|
||||
@@ -239,24 +255,33 @@ watch(
|
||||
</template>
|
||||
|
||||
<!-- Upload Overlay and Input -->
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
<slot
|
||||
v-if="allowUpload || $slots.overlay"
|
||||
name="overlay"
|
||||
:size="size"
|
||||
:handle-upload="handleUploadAvatar"
|
||||
:file-input-ref="fileInput"
|
||||
:handle-image-upload="handleImageUpload"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white"
|
||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white"
|
||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||
/>
|
||||
<input
|
||||
v-if="allowUpload"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useChannelIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -9,7 +10,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const channelIcon = useChannelIcon(props.inbox);
|
||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -22,15 +22,21 @@ export function useChannelIcon(inbox) {
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
const type = inbox.channel_type;
|
||||
const inboxDetails = inbox.value || inbox;
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeIconMap[type];
|
||||
|
||||
if (type === 'Channel::Email' && inbox.provider) {
|
||||
if (Object.keys(providerIconMap).includes(inbox.provider)) {
|
||||
icon = providerIconMap[inbox.provider];
|
||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for Twilio whatsapp
|
||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||
icon = 'i-ri-whatsapp-fill';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,77 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-ri-phone-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Line channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Line' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-line-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for SMS channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Sms' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Telegram channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Telegram' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-telegram-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Twitter channel', () => {
|
||||
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-twitter-x-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for WebWidget channel', () => {
|
||||
const inbox = { channel_type: 'Channel::WebWidget' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-global-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Instagram channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Instagram' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-instagram-fill');
|
||||
});
|
||||
|
||||
describe('TwilioSms channel', () => {
|
||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||
});
|
||||
|
||||
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'sms',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns chat icon for Twilio SMS with undefined medium', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: undefined,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email channel', () => {
|
||||
it('returns mail icon for generic email channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Email' };
|
||||
|
||||
@@ -98,7 +98,7 @@ const toggleConversationLayout = () => {
|
||||
/>
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-40 mt-2"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ const toggleConversationLayout = () => {
|
||||
/>
|
||||
<div
|
||||
id="conversationFilterTeleportTarget"
|
||||
class="absolute z-40 mt-2"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@ const toggleConversationLayout = () => {
|
||||
/>
|
||||
<div
|
||||
id="conversationFilterTeleportTarget"
|
||||
class="absolute z-40 mt-2"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
<script>
|
||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
computed: {
|
||||
computedInboxClass() {
|
||||
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
|
||||
const classByType = getInboxClassByType(type, phoneNumber);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||
<fluent-icon
|
||||
class="ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
@@ -44,6 +44,7 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
@@ -56,15 +57,17 @@ const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const contactById = useMapGetter('contacts/getContact');
|
||||
const inboxById = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return contactById.value(chatMetadata.value.sender.id);
|
||||
return senderId.value
|
||||
? store.getters['contacts/getContact'](senderId.value)
|
||||
: {};
|
||||
});
|
||||
|
||||
const isActiveChat = computed(() => {
|
||||
@@ -79,10 +82,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
const { inbox_id: inboxId } = props.chat;
|
||||
const stateInbox = inboxById.value(inboxId);
|
||||
return stateInbox;
|
||||
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => {
|
||||
@@ -241,28 +244,34 @@ const deleteConversation = () => {
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:username="currentContact.name"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
size="32px"
|
||||
:inbox="inbox"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
/>
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
|
||||
@@ -732,7 +732,7 @@
|
||||
"HOME_PAGE_LINK": {
|
||||
"LABEL": "Home page link",
|
||||
"PLACEHOLDER": "Portal home page link",
|
||||
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
|
||||
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
|
||||
},
|
||||
"SLUG": {
|
||||
"LABEL": "Slug",
|
||||
|
||||
@@ -72,6 +72,24 @@
|
||||
"MARK_ALL_READ": "All notifications marked as read",
|
||||
"DELETE_ALL": "All notifications deleted",
|
||||
"DELETE_ALL_READ": "All read notifications deleted"
|
||||
},
|
||||
"REAUTHORIZE": {
|
||||
"TITLE": "Reauthorization Required",
|
||||
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
|
||||
"BUTTON_TEXT": "Reconnect WhatsApp",
|
||||
"LOADING_FACEBOOK": "Loading Facebook SDK...",
|
||||
"SUCCESS": "WhatsApp reconnected successfully",
|
||||
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
||||
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
|
||||
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
|
||||
"TROUBLESHOOTING": {
|
||||
"TITLE": "Troubleshooting",
|
||||
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
|
||||
"COOKIES": "Third-party cookies must be enabled",
|
||||
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,11 @@
|
||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
},
|
||||
"LEARN_MORE": {
|
||||
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
|
||||
"LINK_TEXT": "this link.",
|
||||
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
@@ -598,6 +603,21 @@
|
||||
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
||||
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
||||
"WHATSAPP_CONNECT_BUTTON": "Connect",
|
||||
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
|
||||
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||
|
||||
@@ -102,7 +102,7 @@ const visibleInfoItems = computed(() =>
|
||||
>
|
||||
<InboxName
|
||||
:inbox="inbox"
|
||||
class="mr-2 rtl:mr-0 rtl:ml-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
|
||||
class="mx-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
@@ -44,6 +45,7 @@ export default {
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
InstagramReauthorize,
|
||||
WhatsappReauthorize,
|
||||
DuplicateInboxBanner,
|
||||
Editor,
|
||||
},
|
||||
@@ -87,10 +89,7 @@ export default {
|
||||
return this.tabs[this.selectedTabIndex]?.key;
|
||||
},
|
||||
shouldShowWhatsAppConfiguration() {
|
||||
return !!(
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.inbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
return this.isAWhatsAppCloudChannel;
|
||||
},
|
||||
whatsAppAPIProviderName() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
@@ -247,6 +246,14 @@ export default {
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
this.inbox.provider === 'whatsapp_cloud' &&
|
||||
this.inbox.provider_config?.source === 'embedded_signup' &&
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to) {
|
||||
@@ -416,6 +423,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')"
|
||||
|
||||
+61
-87
@@ -7,8 +7,13 @@ import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from './whatsapp/utils';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -102,7 +107,7 @@ const completeSignupFlow = async businessDataParam => {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
phone_number_id: businessDataParam?.phone_number_id || '',
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
@@ -120,17 +125,12 @@ const completeSignupFlow = async businessDataParam => {
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
businessDataLocal.business_id &&
|
||||
businessDataLocal.waba_id
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (data.event === 'FINISH') {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
@@ -162,9 +162,26 @@ const handleEmbeddedSignupData = async data => {
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
authCode.value = response.authResponse.code;
|
||||
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
fbSdkLoaded.value = true;
|
||||
|
||||
const code = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
|
||||
authCode.value = code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
@@ -173,79 +190,18 @@ const fbLoginCallback = response => {
|
||||
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 => {
|
||||
// Validate origin for security - following Facebook documentation
|
||||
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
|
||||
if (!event.origin.endsWith('facebook.com')) return;
|
||||
|
||||
// Parse and handle WhatsApp embedded signup events
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or irrelevant messages
|
||||
}
|
||||
};
|
||||
|
||||
const runFBInit = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
autoLogAppEvents: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadFacebookSdk = async () => {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
async: true,
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
};
|
||||
|
||||
const tryWhatsAppLogin = () => {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
// Load SDK first if not loaded, following Facebook.vue pattern exactly
|
||||
await loadFacebookSdk();
|
||||
runFBInit(); // Initialize FB after loading
|
||||
|
||||
// Now proceed with login
|
||||
tryWhatsAppLogin();
|
||||
} catch (error) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
if (error.message === 'Login cancelled') {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else {
|
||||
handleSignupError({
|
||||
error:
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,7 +215,6 @@ const cleanupMessageListener = () => {
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
window.fbAsyncInit = runFBInit;
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
@@ -310,6 +265,25 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }}
|
||||
{{ ' ' }}
|
||||
<a
|
||||
:href="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_URL')
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline text-primary"
|
||||
>
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT')
|
||||
}}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating"
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
|
||||
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from './utils';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const isLoadingFacebook = ref(true);
|
||||
|
||||
const whatsappAppId = computed(() => window.chatwootConfig.whatsappAppId);
|
||||
const whatsappConfigurationId = computed(
|
||||
() => window.chatwootConfig.whatsappConfigurationId
|
||||
);
|
||||
|
||||
const reauthorizeWhatsApp = async params => {
|
||||
isRequestingAuthorization.value = true;
|
||||
|
||||
try {
|
||||
const response = await whatsappChannel.reauthorizeWhatsApp({
|
||||
inboxId: props.inbox.id,
|
||||
...params,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
useAlert(t('INBOX.REAUTHORIZE.SUCCESS'));
|
||||
} else {
|
||||
useAlert(response.data.message || t('INBOX.REAUTHORIZE.ERROR'));
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('INBOX.REAUTHORIZE.ERROR'));
|
||||
} finally {
|
||||
isRequestingAuthorization.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmbeddedSignupEvents = async (data, authCode) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
if (data.event === 'FINISH') {
|
||||
const businessData = data.data;
|
||||
|
||||
if (isValidBusinessData(businessData) && businessData.phone_number_id) {
|
||||
await reauthorizeWhatsApp({
|
||||
code: authCode,
|
||||
business_id: businessData.business_id,
|
||||
waba_id: businessData.waba_id,
|
||||
phone_number_id: businessData.phone_number_id,
|
||||
});
|
||||
} else {
|
||||
useAlert(
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA')
|
||||
);
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
isRequestingAuthorization.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else if (data.event === 'error') {
|
||||
isRequestingAuthorization.value = false;
|
||||
useAlert(
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startEmbeddedSignup = authCode => {
|
||||
const messageHandler = createMessageHandler(data =>
|
||||
handleEmbeddedSignupEvents(data, authCode)
|
||||
);
|
||||
window.addEventListener('message', messageHandler);
|
||||
};
|
||||
|
||||
const handleLoginAndReauthorize = async () => {
|
||||
// Validate required configuration
|
||||
if (!whatsappAppId.value) {
|
||||
throw new Error('WhatsApp App ID is required');
|
||||
}
|
||||
if (!whatsappConfigurationId.value) {
|
||||
throw new Error('WhatsApp Configuration ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const authCode = await initWhatsAppEmbeddedSignup(
|
||||
whatsappConfigurationId.value
|
||||
);
|
||||
|
||||
// Check if this is a reauthorization scenario where we already have the business data
|
||||
const existingConfig = props.inbox.provider_config;
|
||||
if (
|
||||
existingConfig &&
|
||||
existingConfig.business_account_id &&
|
||||
existingConfig.phone_number_id
|
||||
) {
|
||||
await reauthorizeWhatsApp({
|
||||
code: authCode,
|
||||
business_id: existingConfig.business_account_id,
|
||||
waba_id: existingConfig.business_account_id,
|
||||
phone_number_id: existingConfig.phone_number_id,
|
||||
});
|
||||
} else {
|
||||
startEmbeddedSignup(authCode);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Login cancelled') {
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED')
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const requestAuthorization = async () => {
|
||||
if (isLoadingFacebook.value) {
|
||||
useAlert(t('INBOX.REAUTHORIZE.LOADING_FACEBOOK'));
|
||||
return;
|
||||
}
|
||||
|
||||
isRequestingAuthorization.value = true;
|
||||
try {
|
||||
await handleLoginAndReauthorize();
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('INBOX.REAUTHORIZE.CONFIGURATION_ERROR'));
|
||||
} finally {
|
||||
// Reset only if not already processing through embedded signup
|
||||
if (!window.FB || !window.FB.getLoginStatus) {
|
||||
isRequestingAuthorization.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Validate required configuration
|
||||
if (!whatsappAppId.value) {
|
||||
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_APP_ID_MISSING'));
|
||||
return;
|
||||
}
|
||||
if (!whatsappConfigurationId.value) {
|
||||
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_CONFIG_ID_MISSING'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Facebook SDK and initialize
|
||||
await setupFacebookSdk(
|
||||
whatsappAppId.value,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX.REAUTHORIZE.FACEBOOK_LOAD_ERROR'));
|
||||
} finally {
|
||||
isLoadingFacebook.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose requestAuthorization function for parent components
|
||||
defineExpose({
|
||||
requestAuthorization,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
|
||||
export const loadFacebookSdk = async () => {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
async: true,
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
};
|
||||
|
||||
export const initializeFacebook = (appId, apiVersion) => {
|
||||
const version = apiVersion || 'v22.0';
|
||||
return new Promise(resolve => {
|
||||
const init = () => {
|
||||
window.FB.init({
|
||||
appId,
|
||||
autoLogAppEvents: true,
|
||||
xfbml: true,
|
||||
version,
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (window.FB) {
|
||||
init();
|
||||
} else {
|
||||
window.fbAsyncInit = init;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const isValidBusinessData = businessData => {
|
||||
return businessData && businessData.business_id && businessData.waba_id;
|
||||
};
|
||||
|
||||
export const createMessageHandler = onEmbeddedSignupData => {
|
||||
return event => {
|
||||
if (!event.origin.endsWith('facebook.com')) return;
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (typeof event.data === 'string') {
|
||||
data = JSON.parse(event.data);
|
||||
} else if (typeof event.data === 'object' && event.data !== null) {
|
||||
data = event.data;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
onEmbeddedSignupData(data);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or irrelevant messages
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const initWhatsAppEmbeddedSignup = configId => {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.FB.login(
|
||||
response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
resolve(response.authResponse.code);
|
||||
} else if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
} else {
|
||||
reject(new Error('Login cancelled'));
|
||||
}
|
||||
},
|
||||
{
|
||||
config_id: configId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: 'whatsapp_business_app_onboarding',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const setupFacebookSdk = async (appId, apiVersion) => {
|
||||
const version = apiVersion || 'v22.0';
|
||||
await loadFacebookSdk();
|
||||
await initializeFacebook(appId, version);
|
||||
};
|
||||
+94
-37
@@ -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 WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -14,6 +15,7 @@ export default {
|
||||
ImapSettings,
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
WhatsappReauthorize,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -29,12 +31,21 @@ export default {
|
||||
return {
|
||||
hmacMandatory: false,
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
whatsAppInboxAPIKey: { required },
|
||||
},
|
||||
computed: {
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappAppId() {
|
||||
return window.chatwootConfig?.whatsappAppId;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
@@ -84,6 +95,11 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async handleReconfigure() {
|
||||
if (this.$refs.whatsappReauth) {
|
||||
await this.$refs.whatsappReauth.requestAuthorization();
|
||||
}
|
||||
},
|
||||
async syncTemplates() {
|
||||
this.isSyncingTemplates = true;
|
||||
try {
|
||||
@@ -210,45 +226,80 @@ export default {
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
|
||||
<div v-if="inbox.provider_config" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsSection
|
||||
v-if="whatsappAppId"
|
||||
:title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
|
||||
"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppInboxAPIKey"
|
||||
type="text"
|
||||
class="flex-1 mr-2 [&>input]:!mb-0"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="v$.whatsAppInboxAPIKey.$invalid"
|
||||
@click="updateWhatsAppInboxAPIKey"
|
||||
<div class="flex gap-4 items-center">
|
||||
<p class="text-sm text-slate-600">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<NextButton @click="handleReconfigure">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
<template v-else>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<woot-input
|
||||
v-model="whatsAppInboxAPIKey"
|
||||
type="text"
|
||||
class="flex-1 mr-2 [&>input]:!mb-0"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="v$.whatsAppInboxAPIKey.$invalid"
|
||||
@click="updateWhatsAppInboxAPIKey"
|
||||
>
|
||||
{{
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON')
|
||||
}}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
@@ -262,6 +313,12 @@ export default {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<WhatsappReauthorize
|
||||
v-if="isEmbeddedSignupWhatsApp"
|
||||
ref="whatsappReauth"
|
||||
:inbox="inbox"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -167,7 +167,14 @@ const contains = (filterValue, conversationValue) => {
|
||||
*/
|
||||
const compareDates = (conversationValue, filterValue, compareFn) => {
|
||||
const conversationDate = coerceToDate(conversationValue);
|
||||
const filterDate = coerceToDate(filterValue);
|
||||
|
||||
// In saved views, the filterValue might be returned as an Array
|
||||
// In conversation list, when filtering, the filterValue will be returned as a string
|
||||
const valueToCompare = Array.isArray(filterValue)
|
||||
? filterValue[0]
|
||||
: filterValue;
|
||||
const filterDate = coerceToDate(valueToCompare);
|
||||
|
||||
if (conversationDate === null || filterDate === null) return false;
|
||||
return compareFn(conversationDate, filterDate);
|
||||
};
|
||||
|
||||
+66
@@ -602,6 +602,59 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
// Test for array filter values (saved views)
|
||||
it('should handle array filter values for date comparison in saved views', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['2022-03-19'], // Array format from saved views
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle array filter values with is_less_than operator', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-03-21'], // Array format from saved views
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle array filter values with timestamp', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [1647691200], // March 19, 2022 as array (in seconds)
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty array filter values', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [], // Empty array
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-date string values in date comparison', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
@@ -721,6 +774,19 @@ describe('filterHelpers', () => {
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle array values for days_before operator', () => {
|
||||
const conversation = { created_at: 1647777600000 }; // March 20, 2022
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'days_before',
|
||||
values: ['3'], // Array format from saved views
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Multiple filters tests
|
||||
|
||||
+17
-15
@@ -2,24 +2,26 @@
|
||||
#
|
||||
# Table name: account_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# agent_capacity_policy_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
|
||||
class AccountUser < ApplicationRecord
|
||||
|
||||
@@ -32,8 +32,8 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validates :phone_number, presence: true, uniqueness: true
|
||||
validate :validate_provider_config
|
||||
|
||||
before_save :setup_webhooks
|
||||
after_create :sync_templates
|
||||
after_create_commit :setup_webhooks
|
||||
before_destroy :teardown_webhooks
|
||||
|
||||
def name
|
||||
@@ -78,8 +78,12 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
handle_webhook_setup_error(e)
|
||||
end
|
||||
|
||||
def provider_config_changed?
|
||||
will_save_change_to_provider_config?
|
||||
end
|
||||
|
||||
def should_setup_webhooks?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
|
||||
@@ -61,6 +61,9 @@ class Twilio::IncomingMessageService
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_name_if_needed
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
@@ -173,4 +176,18 @@ class Twilio::IncomingMessageService
|
||||
coordinates_long: params[:Longitude].to_f
|
||||
)
|
||||
end
|
||||
|
||||
def update_contact_name_if_needed
|
||||
return if params[:ProfileName].blank?
|
||||
return if @contact.name == params[:ProfileName]
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: params[:ProfileName])
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
def initialize(account:, params:, inbox_id: nil)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@code = params[:code]
|
||||
@business_id = params[:business_id]
|
||||
@waba_id = params[:waba_id]
|
||||
@phone_number_id = params[:phone_number_id]
|
||||
@inbox_id = inbox_id
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -19,11 +20,19 @@ class Whatsapp::EmbeddedSignupService
|
||||
# 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
|
||||
# Reauthorization flow if inbox_id is present
|
||||
if @inbox_id.present?
|
||||
Whatsapp::ReauthorizationService.new(
|
||||
account: @account,
|
||||
inbox_id: @inbox_id,
|
||||
phone_number_id: @phone_number_id,
|
||||
business_id: @business_id
|
||||
).perform(access_token, phone_info)
|
||||
else
|
||||
# Create channel for new authorization
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
|
||||
@@ -50,6 +50,16 @@ class Whatsapp::FacebookApiClient
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def phone_number_verified?(phone_number_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
data = handle_response(response, 'Phone status check failed')
|
||||
data['code_verification_status'] == 'VERIFIED'
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::ReauthorizationService
|
||||
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
|
||||
@account = account
|
||||
@inbox_id = inbox_id
|
||||
@phone_number_id = phone_number_id
|
||||
@business_id = business_id
|
||||
end
|
||||
|
||||
def perform(access_token, phone_info)
|
||||
inbox = @account.inboxes.find(@inbox_id)
|
||||
channel = inbox.channel
|
||||
|
||||
# Validate phone number matches for reauthorization
|
||||
if phone_info[:phone_number] != channel.phone_number
|
||||
raise StandardError, "Phone number mismatch. Expected #{channel.phone_number}, got #{phone_info[:phone_number]}"
|
||||
end
|
||||
|
||||
# Update channel configuration
|
||||
update_channel_config(channel, access_token, phone_info)
|
||||
# Mark as reauthorized
|
||||
channel.reauthorized! if channel.respond_to?(:reauthorized!)
|
||||
|
||||
channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_channel_config(channel, access_token, phone_info)
|
||||
current_config = channel.provider_config || {}
|
||||
channel.provider_config = current_config.merge(
|
||||
'api_key' => access_token,
|
||||
'phone_number_id' => @phone_number_id,
|
||||
'business_account_id' => @business_id,
|
||||
'source' => 'embedded_signup'
|
||||
)
|
||||
channel.save!
|
||||
|
||||
# Update inbox name if business name changed
|
||||
business_name = phone_info[:business_name] || phone_info[:verified_name]
|
||||
channel.inbox.update!(name: business_name) if business_name.present?
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,8 @@ class Whatsapp::WebhookSetupService
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
register_phone_number
|
||||
# Since coexistence method does not need to register, we check it
|
||||
register_phone_number unless phone_number_verified?
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
@@ -64,4 +65,13 @@ class Whatsapp::WebhookSetupService
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
|
||||
def phone_number_verified?
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
|
||||
@api_client.phone_number_verified?(phone_number_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
|
||||
if resource.whatsapp?
|
||||
json.message_templates resource.channel.try(:message_templates)
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
end
|
||||
|
||||
@@ -53,9 +53,15 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
|
||||
<% end %>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
@installation_configs[key]['options'].map { |val, label| [label, val] },
|
||||
{ selected: @app_config[key] },
|
||||
class: "mt-2 border border-slate-100 p-1 rounded-md"
|
||||
%>
|
||||
<% else %>
|
||||
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
|
||||
<% end %>
|
||||
<%if @installation_configs[key]&.dig('description').present? %>
|
||||
<p class="pt-2 text-xs italic text-slate-400">
|
||||
<%= @installation_configs[key]&.dig('description') %>
|
||||
|
||||
@@ -24,3 +24,13 @@
|
||||
title: 'Add Label to Conversation'
|
||||
description: 'Add a label to a conversation'
|
||||
icon: 'tag'
|
||||
|
||||
- id: faq_lookup
|
||||
title: 'FAQ Lookup'
|
||||
description: 'Search FAQ responses using semantic similarity'
|
||||
icon: 'search'
|
||||
|
||||
- id: handoff
|
||||
title: 'Handoff to Human'
|
||||
description: 'Hand off the conversation to a human agent'
|
||||
icon: 'user-switch'
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# locked: if you don't specify locked attribute in yaml, the default value will be true,
|
||||
# which means the particular config will be locked and won't be available in `super_admin/installation_configs`
|
||||
# premium: These values get overwritten unless the user is on a premium plan
|
||||
# type: The type of the config. Default is text, boolean is also supported
|
||||
# type: The type of the config. Default is text, select and boolean are also supported
|
||||
# options: For select types, its required to have options for the select in the following pattern: "option_value":"Human readable option"
|
||||
|
||||
# ------- Branding Related Config ------- #
|
||||
- name: INSTALLATION_NAME
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
|
||||
en:
|
||||
hello: 'Hello world'
|
||||
inbox:
|
||||
reauthorization:
|
||||
success: 'Channel reauthorized successfully'
|
||||
not_required: 'Reauthorization is not required for this inbox'
|
||||
invalid_channel: 'Invalid channel type for reauthorization'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
@@ -67,6 +72,13 @@ en:
|
||||
invalid_message_type: 'Invalid message type. Action not permitted'
|
||||
slack:
|
||||
invalid_channel_id: 'Invalid slack channel. Please try again'
|
||||
whatsapp:
|
||||
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
inboxes:
|
||||
imap:
|
||||
socket_error: Please check the network connection, IMAP address and try again.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddNotificationsPerformanceIndex < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
# Add composite index to optimize notification count queries
|
||||
# This covers the common query pattern: WHERE user_id = ? AND account_id = ? AND snoozed_until IS NULL AND read_at IS NULL
|
||||
add_index :notifications, [:user_id, :account_id, :snoozed_until, :read_at],
|
||||
name: 'idx_notifications_performance',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :assignment_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
|
||||
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
|
||||
t.integer :fair_distribution_limit, null: false, default: 100
|
||||
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :assignment_policies, [:account_id, :name], unique: true
|
||||
add_index :assignment_policies, :enabled
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_assignment_policies do |t|
|
||||
t.references :inbox, null: false, index: true
|
||||
t.references :assignment_policy, null: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :agent_capacity_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.jsonb :exclusion_rules, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_capacity_limits do |t|
|
||||
t.references :agent_capacity_policy, null: false, index: true
|
||||
t.references :inbox, null: false, index: true
|
||||
t.integer :conversation_limit, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
t.references :account, null: false
|
||||
t.references :user, null: false
|
||||
t.date :start_date, null: false
|
||||
t.date :end_date, null: false
|
||||
t.integer :leave_type, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
t.text :reason
|
||||
t.references :approved_by
|
||||
t.datetime :approved_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :leaves, [:account_id, :status]
|
||||
end
|
||||
end
|
||||
+68
-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_07_22_152516) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_06_140005) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.integer "availability", default: 0, null: false
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||
end
|
||||
@@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||
end
|
||||
|
||||
create_table "agent_capacity_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.jsonb "exclusion_rules", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
@@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "assignment_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 100, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
t.integer "file_type", default: 0
|
||||
t.string "external_url"
|
||||
@@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "inbox_assignment_policies", force: :cascade do |t|
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "assignment_policy_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "inbox_capacity_limits", force: :cascade do |t|
|
||||
t.bigint "agent_capacity_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "conversation_limit", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["agent_capacity_policy_id", "inbox_id"], name: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true
|
||||
t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -800,6 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "leave_type", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "reason"
|
||||
t.bigint "approved_by_id"
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["user_id"], name: "index_leaves_on_user_id"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
@@ -909,6 +975,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
|
||||
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
|
||||
t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
|
||||
t.index ["user_id", "account_id", "snoozed_until", "read_at"], name: "idx_notifications_performance"
|
||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
|
||||
if responses.empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
"No relevant FAQs found for: #{query}"
|
||||
else
|
||||
log_tool_usage('found_results', { query: query, count: responses.size })
|
||||
format_responses(responses)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
description 'Hand off the conversation to a human agent when unable to assist further'
|
||||
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
|
||||
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
# Log the handoff with reason
|
||||
log_tool_usage('tool_handoff', {
|
||||
conversation_id: conversation.id,
|
||||
reason: reason || 'Agent requested handoff'
|
||||
})
|
||||
|
||||
# Use existing handoff mechanism from ResponseBuilderJob
|
||||
trigger_handoff(conversation, reason)
|
||||
|
||||
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
'Failed to handoff conversation'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_handoff(conversation, reason)
|
||||
# post the reason as a private note
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
# This tool could be enhanced to:
|
||||
# 1. Accept team_id parameter for routing to specific teams
|
||||
# 2. Set conversation priority based on handoff reason
|
||||
# 3. Add metadata for intelligent agent assignment
|
||||
# 4. Support escalation levels (L1 -> L2 -> L3)
|
||||
#
|
||||
# Example future signature:
|
||||
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
|
||||
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
|
||||
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
|
||||
end
|
||||
@@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool
|
||||
end
|
||||
|
||||
def normalize_priority(priority)
|
||||
priority == 'nil' || priority.blank? ? nil : priority
|
||||
return nil if priority == 'nil' || priority.blank?
|
||||
|
||||
priority.downcase
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
|
||||
@@ -119,19 +119,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
|
||||
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'
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
},
|
||||
inbox_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)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
@@ -151,19 +150,17 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
|
||||
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
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
inbox_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)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
@@ -300,4 +297,236 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization with inbox_id (reauthorization)' do
|
||||
let(:whatsapp_channel) do
|
||||
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'api_key' => 'test_token',
|
||||
'phone_number_id' => '123456',
|
||||
'business_account_id' => '654321',
|
||||
'source' => 'embedded_signup'
|
||||
})
|
||||
allow(channel).to receive(:validate_provider_config).and_return(true)
|
||||
allow(channel).to receive(:sync_templates).and_return(true)
|
||||
allow(channel).to receive(:setup_webhooks).and_return(true)
|
||||
channel.save!
|
||||
# Call authorization_error! twice to reach the threshold
|
||||
channel.authorization_error!
|
||||
channel.authorization_error!
|
||||
channel
|
||||
end
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
|
||||
context 'when user is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
code: 'auth_code_123',
|
||||
business_id: 'business_123',
|
||||
waba_id: 'waba_123',
|
||||
phone_number_id: 'phone_123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'reauthorizes the WhatsApp channel successfully' do
|
||||
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
|
||||
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
params: {
|
||||
code: 'auth_code_123',
|
||||
business_id: 'business_123',
|
||||
waba_id: 'waba_123',
|
||||
phone_number_id: 'phone_123'
|
||||
},
|
||||
inbox_id: whatsapp_inbox.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(whatsapp_inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['success']).to be true
|
||||
expect(json_response['id']).to eq(whatsapp_inbox.id)
|
||||
end
|
||||
|
||||
it 'handles reauthorization failure' do
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
params: {
|
||||
code: 'auth_code_123',
|
||||
business_id: 'business_123',
|
||||
waba_id: 'waba_123',
|
||||
phone_number_id: 'phone_123'
|
||||
},
|
||||
inbox_id: whatsapp_inbox.id
|
||||
).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform)
|
||||
.and_raise(StandardError, 'Token exchange failed')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['success']).to be false
|
||||
expect(json_response['error']).to eq('Token exchange failed')
|
||||
end
|
||||
|
||||
it 'handles phone number mismatch error' do
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
params: {
|
||||
code: 'auth_code_123',
|
||||
business_id: 'business_123',
|
||||
waba_id: 'waba_123',
|
||||
phone_number_id: 'phone_123'
|
||||
},
|
||||
inbox_id: whatsapp_inbox.id
|
||||
).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform)
|
||||
.and_raise(StandardError, 'Phone number mismatch. The new phone number (+1234567890) does not match ' \
|
||||
'the existing phone number (+15551234567). Please use the same WhatsApp ' \
|
||||
'Business Account that was originally connected.')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['success']).to be false
|
||||
expect(json_response['error']).to include('Phone number mismatch')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not exist' do
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: 0, code: 'test', business_id: 'test', waba_id: 'test' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reauthorization is not required' do
|
||||
let(:fresh_channel) do
|
||||
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'api_key' => 'test_token',
|
||||
'phone_number_id' => '123456',
|
||||
'business_account_id' => '654321',
|
||||
'source' => 'embedded_signup'
|
||||
})
|
||||
allow(channel).to receive(:validate_provider_config).and_return(true)
|
||||
allow(channel).to receive(:sync_templates).and_return(true)
|
||||
allow(channel).to receive(:setup_webhooks).and_return(true)
|
||||
channel.save!
|
||||
# Do NOT call authorization_error! - channel is working fine
|
||||
channel
|
||||
end
|
||||
let(:fresh_inbox) { create(:inbox, channel: fresh_channel, account: account) }
|
||||
|
||||
it 'returns unprocessable entity error' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: fresh_inbox.id },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['success']).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not WhatsApp' do
|
||||
let(:facebook_channel) do
|
||||
stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
|
||||
.to_return(status: 200, body: '{}', headers: {})
|
||||
|
||||
channel = create(:channel_facebook_page, account: account)
|
||||
# Call authorization_error! twice to reach the threshold
|
||||
channel.authorization_error!
|
||||
channel.authorization_error!
|
||||
channel
|
||||
end
|
||||
let(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
|
||||
|
||||
it 'returns unprocessable entity error' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: facebook_inbox.id },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['success']).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
|
||||
end
|
||||
|
||||
it 'returns unprocessable_entity error' do
|
||||
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
|
||||
|
||||
# Stub the embedded signup service to prevent HTTP calls
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
params: {
|
||||
code: 'test',
|
||||
business_id: 'test',
|
||||
waba_id: 'test'
|
||||
},
|
||||
inbox_id: whatsapp_inbox.id
|
||||
).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: whatsapp_inbox.id, code: 'test', business_id: 'test', waba_id: 'test' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
# Agents should get unprocessable_entity since they can find the inbox but channel doesn't need reauth
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized error' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: whatsapp_inbox.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
before do
|
||||
# Create installation config for OpenAI API key to avoid errors
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
|
||||
# Mock embedding service to avoid actual API calls
|
||||
embedding_service = instance_double(Captain::Llm::EmbeddingService)
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1))
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Search FAQ responses using semantic similarity to find relevant answers')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:query)
|
||||
expect(tool.parameters[:query].name).to eq(:query)
|
||||
expect(tool.parameters[:query].type).to eq('string')
|
||||
expect(tool.parameters[:query].description).to eq('The question or topic to search for in the FAQ database')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when FAQs exist' do
|
||||
let(:document) { create(:captain_document, assistant: assistant) }
|
||||
let!(:response1) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
question: 'How to reset password?',
|
||||
answer: 'Click on forgot password link',
|
||||
documentable: document,
|
||||
status: 'approved')
|
||||
end
|
||||
let!(:response2) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
question: 'How to change email?',
|
||||
answer: 'Go to settings and update email',
|
||||
status: 'approved')
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock nearest_neighbors to return our test responses
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(
|
||||
Captain::AssistantResponse.where(id: [response1.id, response2.id])
|
||||
)
|
||||
end
|
||||
|
||||
it 'searches FAQs and returns formatted responses' do
|
||||
result = tool.perform(tool_context, query: 'password reset')
|
||||
|
||||
expect(result).to include('Question: How to reset password?')
|
||||
expect(result).to include('Answer: Click on forgot password link')
|
||||
expect(result).to include('Question: How to change email?')
|
||||
expect(result).to include('Answer: Go to settings and update email')
|
||||
end
|
||||
|
||||
it 'includes source link when document has external_link' do
|
||||
document.update!(external_link: 'https://help.example.com/password')
|
||||
|
||||
result = tool.perform(tool_context, query: 'password')
|
||||
|
||||
expect(result).to include('Source: https://help.example.com/password')
|
||||
end
|
||||
|
||||
it 'logs tool usage for search' do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' })
|
||||
expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 })
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no FAQs found' do
|
||||
before do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
end
|
||||
|
||||
it 'returns no results message' do
|
||||
result = tool.perform(tool_context, query: 'nonexistent topic')
|
||||
expect(result).to eq('No relevant FAQs found for: nonexistent topic')
|
||||
end
|
||||
|
||||
it 'logs tool usage for no results' do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'nonexistent topic' })
|
||||
expect(tool).to receive(:log_tool_usage).with('no_results', { query: 'nonexistent topic' })
|
||||
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank query' do
|
||||
it 'handles empty query' do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
|
||||
result = tool.perform(tool_context, query: '')
|
||||
expect(result).to eq('No relevant FAQs found for: ')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:reason)
|
||||
expect(tool.parameters[:reason].name).to eq(:reason)
|
||||
expect(tool.parameters[:reason].type).to eq('string')
|
||||
expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)')
|
||||
expect(tool.parameters[:reason].required).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with reason provided' do
|
||||
it 'creates a private note with reason and hands off conversation' do
|
||||
reason = 'Customer needs specialized support'
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: reason)
|
||||
expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})")
|
||||
end.to change(Message, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates message with correct attributes' do
|
||||
reason = 'Customer needs specialized support'
|
||||
tool.perform(tool_context, reason: reason)
|
||||
|
||||
created_message = Message.last
|
||||
expect(created_message.content).to eq(reason)
|
||||
expect(created_message.message_type).to eq('outgoing')
|
||||
expect(created_message.private).to be true
|
||||
expect(created_message.sender).to eq(assistant)
|
||||
expect(created_message.account).to eq(account)
|
||||
expect(created_message.inbox).to eq(inbox)
|
||||
expect(created_message.conversation).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'triggers bot handoff on conversation' do
|
||||
# The tool finds the conversation by ID, so we need to mock the found conversation
|
||||
found_conversation = Conversation.find(conversation.id)
|
||||
scoped_conversations = Conversation.where(account_id: assistant.account_id)
|
||||
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
|
||||
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
|
||||
expect(found_conversation).to receive(:bot_handoff!)
|
||||
|
||||
tool.perform(tool_context, reason: 'Test reason')
|
||||
end
|
||||
|
||||
it 'logs tool usage with reason' do
|
||||
reason = 'Customer needs help'
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'tool_handoff',
|
||||
{ conversation_id: conversation.id, reason: reason }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, reason: reason)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without reason provided' do
|
||||
it 'creates a private note with nil content and hands off conversation' do
|
||||
expect do
|
||||
result = tool.perform(tool_context)
|
||||
expect(result).to eq('Conversation handed off to human support team')
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
created_message = Message.last
|
||||
expect(created_message.content).to be_nil
|
||||
end
|
||||
|
||||
it 'logs tool usage with default reason' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'tool_handoff',
|
||||
{ conversation_id: conversation.id, reason: 'Agent requested handoff' }
|
||||
)
|
||||
|
||||
tool.perform(tool_context)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff fails' do
|
||||
before do
|
||||
# Mock the conversation lookup and handoff failure
|
||||
found_conversation = Conversation.find(conversation.id)
|
||||
scoped_conversations = Conversation.where(account_id: assistant.account_id)
|
||||
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
|
||||
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
|
||||
allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error')
|
||||
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Failed to handoff conversation')
|
||||
end
|
||||
|
||||
it 'captures exception' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker)
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
tool.perform(tool_context, reason: 'Test')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
|
||||
it 'does not create a message' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Test')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -66,14 +66,31 @@ RSpec.describe NotificationFinder do
|
||||
expect(subject.unread_count).to eq(3)
|
||||
expect(subject.count).to eq(3)
|
||||
end
|
||||
|
||||
it 'avoids duplicate filtering in unread_count method' do
|
||||
# Test the logical fix: when no 'read' filter is included,
|
||||
# @notifications is already filtered to unread, so unread_count
|
||||
# should just count without adding another read_at filter
|
||||
|
||||
allow(subject.instance_variable_get(:@notifications)).to receive(:where).and_call_original
|
||||
allow(subject.instance_variable_get(:@notifications)).to receive(:count).and_call_original
|
||||
|
||||
result = subject.unread_count
|
||||
|
||||
# Should return correct count without additional where clause
|
||||
expect(result).to eq(3)
|
||||
|
||||
# The fix ensures that when params[:includes] doesn't contain 'read',
|
||||
# unread_count uses @notifications.count instead of @notifications.where(read_at: nil).count
|
||||
end
|
||||
end
|
||||
|
||||
context 'with filters applied' do
|
||||
let(:params) { { includes: %w[read snoozed] } }
|
||||
|
||||
it 'adjusts counts based on included statuses' do
|
||||
expect(subject.unread_count).to eq(4)
|
||||
expect(subject.count).to eq(6)
|
||||
expect(subject.unread_count).to eq(4) # 3 unread + 1 snoozed (which is unread)
|
||||
expect(subject.count).to eq(6) # all notifications including read and snoozed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -327,6 +327,81 @@ describe Twilio::IncomingMessageService do
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('1234567890')
|
||||
end
|
||||
|
||||
it 'updates existing contact name when current name matches phone number' do
|
||||
# Create contact with phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: '+1234567890',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Jane Smith'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Jane Smith')
|
||||
end
|
||||
|
||||
it 'does not update contact name when current name is different from phone number' do
|
||||
# Create contact with human name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: 'John Doe',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Jane Smith'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('John Doe') # Should not change
|
||||
end
|
||||
|
||||
it 'updates contact name when current name matches formatted phone number' do
|
||||
# Create contact with formatted phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: '1234567890',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Alice Johnson'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Alice Johnson')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,17 +2,18 @@ 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(:params) do
|
||||
{
|
||||
code: 'test_authorization_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_number_id'
|
||||
}
|
||||
end
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
params: params
|
||||
)
|
||||
end
|
||||
|
||||
@@ -20,37 +21,40 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number_id: params[: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) }
|
||||
let(:service_doubles) do
|
||||
{
|
||||
token_exchange: instance_double(Whatsapp::TokenExchangeService),
|
||||
phone_info: instance_double(Whatsapp::PhoneInfoService),
|
||||
token_validation: instance_double(Whatsapp::TokenValidationService),
|
||||
channel_creation: instance_double(Whatsapp::ChannelCreationService)
|
||||
}
|
||||
end
|
||||
|
||||
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::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
|
||||
allow(service_doubles[:token_exchange]).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)
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
|
||||
allow(service_doubles[:phone_info]).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)
|
||||
.with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
|
||||
allow(service_doubles[:token_validation]).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)
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
.and_return(service_doubles[:channel_creation])
|
||||
allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
|
||||
|
||||
# Webhook setup is now handled in the channel after_create callback
|
||||
# So we stub it at the model level
|
||||
@@ -60,10 +64,10 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
end
|
||||
|
||||
it 'orchestrates all services in the correct order' do
|
||||
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
|
||||
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
|
||||
expect(service_doubles[:phone_info]).to receive(:perform).ordered
|
||||
expect(service_doubles[:token_validation]).to receive(:perform).ordered
|
||||
expect(service_doubles[:channel_creation]).to receive(:perform).ordered
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
@@ -73,10 +77,7 @@ describe Whatsapp::EmbeddedSignupService 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
|
||||
params: params.merge(code: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
|
||||
end
|
||||
@@ -84,10 +85,7 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
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
|
||||
params: params.merge(business_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
|
||||
end
|
||||
@@ -95,10 +93,7 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
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
|
||||
params: params.merge(waba_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
|
||||
end
|
||||
@@ -106,10 +101,7 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
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
|
||||
params: params.merge(code: '', business_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
|
||||
end
|
||||
@@ -117,11 +109,44 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
|
||||
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')
|
||||
allow(service_doubles[:token_exchange]).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
|
||||
|
||||
context 'when inbox_id is provided (reauthorization flow)' do
|
||||
let(:inbox_id) { 123 }
|
||||
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
|
||||
let(:service_with_inbox) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
params: params,
|
||||
inbox_id: inbox_id
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox_id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
|
||||
end
|
||||
|
||||
it 'uses ReauthorizationService instead of ChannelCreationService' do
|
||||
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
|
||||
expect(service_doubles[:phone_info]).to receive(:perform).ordered
|
||||
expect(service_doubles[:token_validation]).to receive(:perform).ordered
|
||||
expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
|
||||
expect(service_doubles[:channel_creation]).not_to receive(:perform)
|
||||
|
||||
result = service_with_inbox.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,25 +24,19 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when all operations succeed' do
|
||||
context 'when phone number is NOT verified (should register)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
|
||||
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 })
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'registers the phone number' do
|
||||
it 'registers the phone number and sets up webhook' 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
|
||||
@@ -50,33 +44,71 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone registration fails' do
|
||||
context 'when phone number IS verified (should NOT register)' 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(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'continues with webhook setup' do
|
||||
it 'does NOT register phone, but sets up webhook' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
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_number_verified? raises error' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down')
|
||||
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_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'tries to register phone and proceeds with webhook setup' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup fails' do
|
||||
context 'when phone registration fails (not blocking)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
|
||||
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 })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'continues with webhook setup even if registration fails' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup fails (should raise)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
|
||||
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')
|
||||
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(api_client).to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.to raise_error(/Webhook setup failed/)
|
||||
end
|
||||
end
|
||||
@@ -84,24 +116,25 @@ describe Whatsapp::WebhookSetupService do
|
||||
|
||||
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')
|
||||
service_invalid = described_class.new(nil, waba_id, access_token)
|
||||
expect { service_invalid.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')
|
||||
service_invalid = described_class.new(channel, '', access_token)
|
||||
expect { service_invalid.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')
|
||||
service_invalid = described_class.new(channel, waba_id, '')
|
||||
expect { service_invalid.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(:phone_number_verified?).with('123456789').and_return(false)
|
||||
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!)
|
||||
|
||||
@@ -7,6 +7,9 @@ RSpec.describe Whatsapp::WebhookTeardownService do
|
||||
|
||||
context 'when channel is whatsapp_cloud with embedded_signup' do
|
||||
before do
|
||||
# Stub webhook setup to prevent HTTP calls during channel update
|
||||
allow(channel).to receive(:setup_webhooks).and_return(true)
|
||||
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
|
||||
Reference in New Issue
Block a user