feat(zapi): Z-API integration (#115)
* feat(zapi): connect flow and UI updates * fix(zapi): re-add manage connection section * feat(zapi): reply * feat: send message * fix: qrcode job logic * test: qr code job specs * chore: concurrent index * chore: whatsapp model minor * test: message window service specs * chore: service refactor * test: zapi service * chore: zapi beta * chore: minor fixes * test: incoming message specs * chore: minor fixes * feat: handle status transitions * test: refactor spec * test: refactor spec * chore(z-api): use feature flag * chore: fix migration name
This commit is contained in:
parent
4cb185c501
commit
4fc80ba4ee
@ -17,6 +17,7 @@ const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isWhatsappBaileysInbox: { type: Boolean, default: false },
|
||||
isWhatsAppZapiInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
@ -80,7 +81,8 @@ const shouldShowEmojiButton = computed(() => {
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return (
|
||||
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
|
||||
props.isWhatsappBaileysInbox
|
||||
props.isWhatsappBaileysInbox ||
|
||||
props.isWhatsAppZapiInbox
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -69,6 +69,9 @@ const inboxTypes = computed(() => ({
|
||||
isWhatsappBaileys:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'baileys',
|
||||
isWhatsappZapi:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'zapi',
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isEmailOrWebWidget:
|
||||
@ -284,7 +287,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
(!inboxTypes.value.isWhatsapp || inboxTypes.value.isWhatsappBaileys) &&
|
||||
(!inboxTypes.value.isWhatsapp ||
|
||||
inboxTypes.value.isWhatsappBaileys ||
|
||||
inboxTypes.value.isWhatsappZapi) &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
@ -358,6 +363,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
|
||||
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import 'highlight.js/styles/default.css';
|
||||
import 'highlight.js/lib/common';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@ -24,10 +24,20 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Chatwoot Codepen',
|
||||
},
|
||||
secure: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isVisible = ref(false);
|
||||
|
||||
const toggleVisibility = () => {
|
||||
isVisible.value = !isVisible.value;
|
||||
};
|
||||
|
||||
const scrubbedScript = computed(() => {
|
||||
// remove trailing and leading extra lines and not spaces
|
||||
const scrubbed = props.script.replace(/^\s*[\r\n]/gm, '');
|
||||
@ -52,6 +62,10 @@ const codepenScriptValue = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const shouldShowScript = computed(() => {
|
||||
return !props.secure || isVisible.value;
|
||||
});
|
||||
|
||||
const onCopy = async e => {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(scrubbedScript.value);
|
||||
@ -80,6 +94,14 @@ const onCopy = async e => {
|
||||
:label="t('COMPONENTS.CODE.CODEPEN')"
|
||||
/>
|
||||
</form>
|
||||
<NextButton
|
||||
v-if="secure"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
:icon="isVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="toggleVisibility"
|
||||
/>
|
||||
<NextButton
|
||||
slate
|
||||
xs
|
||||
@ -89,10 +111,16 @@ const onCopy = async e => {
|
||||
/>
|
||||
</div>
|
||||
<highlightjs
|
||||
v-if="script"
|
||||
v-if="script && shouldShowScript"
|
||||
:language="lang"
|
||||
:code="scrubbedScript"
|
||||
class="[&_code]:text-start"
|
||||
/>
|
||||
<highlightjs
|
||||
v-else-if="script && secure && !isVisible"
|
||||
:language="lang"
|
||||
code="••••••••••••••••••••••••••••••••"
|
||||
class="[&_code]:text-start"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -39,7 +39,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -48,7 +48,7 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
WhatsappBaileysLinkDeviceModal,
|
||||
WhatsappLinkDeviceModal,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@ -98,7 +98,7 @@ export default {
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
showBaileysLinkDeviceModal: false,
|
||||
showLinkDeviceModal: false,
|
||||
};
|
||||
},
|
||||
|
||||
@ -471,11 +471,11 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onOpenBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = true;
|
||||
onOpenLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = true;
|
||||
},
|
||||
onCloseBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = false;
|
||||
onCloseLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = false;
|
||||
},
|
||||
onSetupProviderConnection() {
|
||||
this.store
|
||||
@ -485,7 +485,7 @@ export default {
|
||||
console.error('Error setting up provider connection:', e);
|
||||
useAlert(
|
||||
this.$t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.RECONNECT_FAILED'
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED'
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -496,11 +496,11 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<template v-if="isAWhatsAppBaileysChannel">
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal"
|
||||
:show="showBaileysLinkDeviceModal"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
<template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel">
|
||||
<WhatsappLinkDeviceModal
|
||||
v-if="showLinkDeviceModal"
|
||||
:show="showLinkDeviceModal"
|
||||
:on-close="onCloseLinkDeviceModal"
|
||||
:inbox="currentInbox"
|
||||
/>
|
||||
<Banner
|
||||
@ -510,23 +510,21 @@ export default {
|
||||
:banner-message="
|
||||
isAdmin
|
||||
? $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED'
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED'
|
||||
)
|
||||
: $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
|
||||
)
|
||||
"
|
||||
has-action-button
|
||||
:action-button-label="
|
||||
isAdmin
|
||||
? $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.LINK_DEVICE'
|
||||
)
|
||||
? $t('CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE')
|
||||
: ''
|
||||
"
|
||||
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
|
||||
@primary-action="
|
||||
isAdmin ? onOpenBaileysLinkDeviceModal() : onSetupProviderConnection()
|
||||
isAdmin ? onOpenLinkDeviceModal() : onSetupProviderConnection()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -124,6 +124,13 @@ export const useInbox = (inboxId = null) => {
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppZapiChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'zapi'
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||
@ -155,6 +162,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWhatsAppBaileysChannel,
|
||||
isAWhatsAppZapiChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
|
||||
@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SAML: 'saml',
|
||||
CHANNEL_ZAPI: 'channel_zapi',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@ -271,7 +271,7 @@
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"INBOX": {
|
||||
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
|
||||
"WHATSAPP_PROVIDER_CONNECTION": {
|
||||
"NOT_CONNECTED": "WhatsApp is not connected. Please link your device again.",
|
||||
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. Click this button to try to reconnect, or please contact your administrator to link your device again.",
|
||||
"LINK_DEVICE": "Link device",
|
||||
|
||||
@ -229,7 +229,9 @@
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"360_DIALOG": "360Dialog",
|
||||
"BAILEYS": "Baileys",
|
||||
"BAILEYS_DESC": "Connect via non-official API Baileys"
|
||||
"BAILEYS_DESC": "Connect via non-official API Baileys",
|
||||
"ZAPI": "Z-API",
|
||||
"ZAPI_DESC": "Connect via non-official API Z-API"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
@ -280,9 +282,24 @@
|
||||
"MARK_AS_READ": {
|
||||
"LABEL": "Send read receipts"
|
||||
},
|
||||
"INSTANCE_ID": {
|
||||
"LABEL": "Instance ID",
|
||||
"PLACEHOLDER": "Please enter your instance ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"TOKEN": {
|
||||
"LABEL": "Token",
|
||||
"PLACEHOLDER": "Please enter your instance Token",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"CLIENT_TOKEN": {
|
||||
"LABEL": "Security Token",
|
||||
"PLACEHOLDER": "Please enter your Security Token (see Security tab on Z-API dashboard)",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"ADVANCED_OPTIONS": "Advanced options",
|
||||
"BAILEYS": {
|
||||
"SUBTITLE": "Click below to setup the WhatsApp channel using Baileys.",
|
||||
"EXTERNAL_PROVIDER": {
|
||||
"SUBTITLE": "Click below to setup the WhatsApp channel.",
|
||||
"LINK_BUTTON": "Link device",
|
||||
"LINK_DEVICE_MODAL": {
|
||||
"TITLE": "Link your device",
|
||||
@ -707,7 +724,17 @@
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
|
||||
"WHATSAPP_MARK_AS_READ_TITLE": "Read receipts",
|
||||
"WHATSAPP_MARK_AS_READ_SUBHEADER": "If turned off, when a message is viewed in Chatwoot, a read receipt will not be sent to the sender. Your messages will still be able to receive read receipts from the sender.",
|
||||
"WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts"
|
||||
"WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts",
|
||||
"WHATSAPP_INSTANCE_ID_TITLE": "Instance ID",
|
||||
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Your Z-API Instance ID.",
|
||||
"WHATSAPP_TOKEN_TITLE": "Token",
|
||||
"WHATSAPP_TOKEN_SUBHEADER": "Your Z-API instance Token.",
|
||||
"WHATSAPP_TOKEN_UPDATE_TITLE": "Update Token",
|
||||
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Enter the new instance Token here",
|
||||
"WHATSAPP_CLIENT_TOKEN_TITLE": "Security Token",
|
||||
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Your Z-API Client Token (see Security tab on Z-API dashboard).",
|
||||
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Update Security Token",
|
||||
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Enter the new Security Token here"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Help Center",
|
||||
@ -969,6 +996,8 @@
|
||||
"TWITTER_PROFILE": "Twitter",
|
||||
"TWILIO_SMS": "Twilio SMS",
|
||||
"WHATSAPP": "WhatsApp",
|
||||
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
|
||||
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
|
||||
"SMS": "SMS",
|
||||
"EMAIL": "Email",
|
||||
"TELEGRAM": "Telegram",
|
||||
|
||||
@ -271,7 +271,7 @@
|
||||
"COPILOT": "Copiloto"
|
||||
},
|
||||
"INBOX": {
|
||||
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
|
||||
"WHATSAPP_PROVIDER_CONNECTION": {
|
||||
"NOT_CONNECTED": "O WhatsApp não está conectado. Por favor conecte o seu dispositivo novamente.",
|
||||
"NOT_CONNECTED_CONTACT_ADMIN": "O WhatsApp não está conectado. Clique no botão ao lado para tentar reconectar, ou contate o seu administrador para conectar o dispositivo novamente.",
|
||||
"LINK_DEVICE": "Conectar dispositivo",
|
||||
|
||||
@ -229,7 +229,9 @@
|
||||
"TWILIO_DESC": "Conectar através de credenciais Twilio",
|
||||
"360_DIALOG": "360Dialog",
|
||||
"BAILEYS": "Baileys",
|
||||
"BAILEYS_DESC": "Conectar via API não-oficial Baileys"
|
||||
"BAILEYS_DESC": "Conectar via API não-oficial Baileys",
|
||||
"ZAPI": "Z-API",
|
||||
"ZAPI_DESC": "Conectar via API não-oficial Z-API"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Selecione seu provedor de API",
|
||||
@ -280,9 +282,24 @@
|
||||
"MARK_AS_READ": {
|
||||
"LABEL": "Enviar confirmações de leitura"
|
||||
},
|
||||
"INSTANCE_ID": {
|
||||
"LABEL": "ID da instância",
|
||||
"PLACEHOLDER": "Por favor, insira o ID da sua instância",
|
||||
"ERROR": "Este campo é obrigatório"
|
||||
},
|
||||
"TOKEN": {
|
||||
"LABEL": "Token",
|
||||
"PLACEHOLDER": "Por favor, insira o Token da sua instância",
|
||||
"ERROR": "Este campo é obrigatório"
|
||||
},
|
||||
"CLIENT_TOKEN": {
|
||||
"LABEL": "Token de Segurança",
|
||||
"PLACEHOLDER": "Por favor, insira o Token de Segurança (veja a aba Segurança no painel do Z-API)",
|
||||
"ERROR": "Este campo é obrigatório"
|
||||
},
|
||||
"ADVANCED_OPTIONS": "Opções avançadas",
|
||||
"BAILEYS": {
|
||||
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp usando o Baileys.",
|
||||
"EXTERNAL_PROVIDER": {
|
||||
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp.",
|
||||
"LINK_BUTTON": "Conectar dispositivo",
|
||||
"LINK_DEVICE_MODAL": {
|
||||
"TITLE": "Conecte o seu dispositivo",
|
||||
@ -706,7 +723,17 @@
|
||||
"WHATSAPP_PROVIDER_URL_ERROR": "Por favor, insira uma URL válida",
|
||||
"WHATSAPP_MARK_AS_READ_TITLE": "Confirmações de leitura",
|
||||
"WHATSAPP_MARK_AS_READ_SUBHEADER": "Se essa opção estiver desativada, ao visualizar uma mensagem pelo Chatwoot, não será enviada uma confirmação de leitura para o remetente. As suas mensagens ainda poderão receber confirmações de leitura.",
|
||||
"WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura"
|
||||
"WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura",
|
||||
"WHATSAPP_INSTANCE_ID_TITLE": "ID da Instância",
|
||||
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Seu ID da Instância Z-API.",
|
||||
"WHATSAPP_TOKEN_TITLE": "Token",
|
||||
"WHATSAPP_TOKEN_SUBHEADER": "Seu Token da Instância Z-API.",
|
||||
"WHATSAPP_TOKEN_UPDATE_TITLE": "Atualizar Token",
|
||||
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token aqui",
|
||||
"WHATSAPP_CLIENT_TOKEN_TITLE": "Token de Segurança",
|
||||
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Seu Token de Segurança Z-API (veja a aba Segurança no painel do Z-API).",
|
||||
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Atualizar Token de Segurança",
|
||||
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token de Segurança aqui"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Centro de Ajuda",
|
||||
@ -968,6 +995,8 @@
|
||||
"TWITTER_PROFILE": "Twitter",
|
||||
"TWILIO_SMS": "SMS Twilio",
|
||||
"WHATSAPP": "WhatsApp",
|
||||
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
|
||||
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
|
||||
"SMS": "SMS",
|
||||
"EMAIL": "e-mail",
|
||||
"TELEGRAM": "Telegram",
|
||||
|
||||
@ -7,7 +7,7 @@ import QRCode from 'qrcode';
|
||||
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import WhatsappBaileysLinkDeviceModal from './components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
import WhatsappLinkDeviceModal from './components/WhatsappLinkDeviceModal.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@ -25,7 +25,7 @@ const currentInbox = computed(() =>
|
||||
store.getters['inboxes/getInbox'](route.params.inbox_id)
|
||||
);
|
||||
|
||||
const showBaileysLinkDeviceModal = reactive({
|
||||
const showLinkDeviceModal = reactive({
|
||||
value: false,
|
||||
});
|
||||
|
||||
@ -33,6 +33,7 @@ const showBaileysLinkDeviceModal = reactive({
|
||||
const {
|
||||
isAWhatsAppCloudChannel,
|
||||
isAWhatsAppBaileysChannel,
|
||||
isAWhatsAppZapiChannel,
|
||||
isATwilioChannel,
|
||||
isASmsInbox,
|
||||
isALineChannel,
|
||||
@ -92,9 +93,9 @@ const message = computed(() => {
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (isAWhatsAppBaileysChannel.value) {
|
||||
if (isAWhatsAppBaileysChannel.value || isAWhatsAppZapiChannel.value) {
|
||||
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.SUBTITLE'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.SUBTITLE'
|
||||
)}`;
|
||||
}
|
||||
|
||||
@ -164,12 +165,12 @@ async function generateQRCodes() {
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenBaileysLinkDeviceModal = () => {
|
||||
showBaileysLinkDeviceModal.value = true;
|
||||
const onOpenLinkDeviceModal = () => {
|
||||
showLinkDeviceModal.value = true;
|
||||
};
|
||||
|
||||
const onCloseBaileysLinkDeviceModal = () => {
|
||||
showBaileysLinkDeviceModal.value = false;
|
||||
const onCloseLinkDeviceModal = () => {
|
||||
showLinkDeviceModal.value = false;
|
||||
};
|
||||
|
||||
// Watch for currentInbox changes and regenerate QR codes when available
|
||||
@ -234,11 +235,11 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isAWhatsAppBaileysChannel"
|
||||
v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<NextButton @click="onOpenBaileysLinkDeviceModal">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_BUTTON') }}
|
||||
<NextButton @click="onOpenLinkDeviceModal">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<div class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
@ -263,7 +264,10 @@ onMounted(() => {
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
isAWhatsAppChannel && !isAWhatsAppBaileysChannel && qrCodes.whatsapp
|
||||
isAWhatsAppChannel &&
|
||||
!isAWhatsAppBaileysChannel &&
|
||||
!isAWhatsAppZapiChannel &&
|
||||
qrCodes.whatsapp
|
||||
"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
@ -337,10 +341,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</EmptyState>
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal.value"
|
||||
:show="showBaileysLinkDeviceModal.value"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
<WhatsappLinkDeviceModal
|
||||
v-if="showLinkDeviceModal.value"
|
||||
:show="showLinkDeviceModal.value"
|
||||
:on-close="onCloseLinkDeviceModal"
|
||||
:inbox="currentInbox"
|
||||
is-setup
|
||||
/>
|
||||
|
||||
@ -125,6 +125,7 @@ const openDelete = inbox => {
|
||||
<ChannelName
|
||||
:channel-type="inbox.channel_type"
|
||||
:medium="inbox.medium"
|
||||
:provider="inbox.provider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -107,6 +107,9 @@ export default {
|
||||
if (this.isAWhatsAppBaileysChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS');
|
||||
}
|
||||
if (this.isAWhatsAppZapiChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
tabs() {
|
||||
@ -157,7 +160,8 @@ export default {
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAWhatsAppBaileysChannel
|
||||
this.isAWhatsAppBaileysChannel ||
|
||||
this.isAWhatsAppZapiChannel
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
|
||||
@ -8,10 +8,14 @@ import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import BaileysWhatsapp from './BaileysWhatsapp.vue';
|
||||
import ZapiWhatsapp from './ZapiWhatsapp.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
@ -21,6 +25,7 @@ const PROVIDER_TYPES = {
|
||||
WHATSAPP_MANUAL: 'whatsapp_manual',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
BAILEYS: 'baileys',
|
||||
ZAPI: 'zapi',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
@ -36,26 +41,39 @@ const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
key: PROVIDER_TYPES.WHATSAPP,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: 'i-woot-whatsapp',
|
||||
},
|
||||
{
|
||||
key: PROVIDER_TYPES.TWILIO,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: 'i-woot-twilio',
|
||||
},
|
||||
{
|
||||
key: PROVIDER_TYPES.BAILEYS,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
|
||||
icon: 'i-woot-baileys',
|
||||
},
|
||||
]);
|
||||
const availableProviders = computed(() => {
|
||||
const providers = [
|
||||
{
|
||||
key: PROVIDER_TYPES.WHATSAPP,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: 'i-woot-whatsapp',
|
||||
},
|
||||
{
|
||||
key: PROVIDER_TYPES.TWILIO,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: 'i-woot-twilio',
|
||||
},
|
||||
{
|
||||
key: PROVIDER_TYPES.BAILEYS,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
|
||||
icon: 'i-woot-baileys',
|
||||
},
|
||||
];
|
||||
|
||||
if (isFeatureFlagEnabled(FEATURE_FLAGS.CHANNEL_ZAPI)) {
|
||||
providers.push({
|
||||
key: PROVIDER_TYPES.ZAPI,
|
||||
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
|
||||
icon: 'i-woot-zapi',
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
});
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
@ -152,6 +170,7 @@ const handleManualLinkClick = () => {
|
||||
<BaileysWhatsapp
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
|
||||
/>
|
||||
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxName = ref('');
|
||||
const phoneNumber = ref('');
|
||||
const instanceId = ref('');
|
||||
const token = ref('');
|
||||
const clientToken = ref('');
|
||||
|
||||
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
||||
|
||||
const rules = computed(() => ({
|
||||
inboxName: { required },
|
||||
phoneNumber: { required, isPhoneE164OrEmpty },
|
||||
instanceId: { required },
|
||||
token: { required },
|
||||
clientToken: { required },
|
||||
}));
|
||||
|
||||
const v$ = useVuelidate(rules, {
|
||||
inboxName,
|
||||
phoneNumber,
|
||||
instanceId,
|
||||
token,
|
||||
clientToken,
|
||||
});
|
||||
|
||||
const createChannel = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
|
||||
name: inboxName.value,
|
||||
channel: {
|
||||
type: 'whatsapp',
|
||||
phone_number: phoneNumber.value,
|
||||
provider: 'zapi',
|
||||
provider_config: {
|
||||
instance_id: instanceId.value,
|
||||
token: token.value,
|
||||
client_token: clientToken.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: whatsappChannel.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.inboxName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||
<input
|
||||
v-model="inboxName"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
|
||||
@blur="v$.inboxName.$touch"
|
||||
/>
|
||||
<span v-if="v$.inboxName.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.phoneNumber.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
|
||||
<input
|
||||
v-model="phoneNumber"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
/>
|
||||
<span v-if="v$.phoneNumber.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.instanceId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.LABEL') }}
|
||||
<input
|
||||
v-model="instanceId"
|
||||
type="password"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.PLACEHOLDER')"
|
||||
@blur="v$.instanceId.$touch"
|
||||
/>
|
||||
<span v-if="v$.instanceId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.token.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.LABEL') }}
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.PLACEHOLDER')"
|
||||
@blur="v$.token.$touch"
|
||||
/>
|
||||
<span v-if="v$.token.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.clientToken.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.LABEL') }}
|
||||
<input
|
||||
v-model="clientToken"
|
||||
type="password"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.PLACEHOLDER')"
|
||||
@blur="v$.clientToken.$touch"
|
||||
/>
|
||||
<span v-if="v$.clientToken.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
type="submit"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
@ -39,6 +43,16 @@ const twilioChannelName = () => {
|
||||
return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`);
|
||||
};
|
||||
|
||||
const whatsappChannelName = () => {
|
||||
if (props.provider === 'baileys') {
|
||||
return t(`INBOX_MGMT.CHANNELS.WHATSAPP_BAILEYS`);
|
||||
}
|
||||
if (props.provider === 'zapi') {
|
||||
return t(`INBOX_MGMT.CHANNELS.WHATSAPP_ZAPI`);
|
||||
}
|
||||
return t(`INBOX_MGMT.CHANNELS.WHATSAPP`);
|
||||
};
|
||||
|
||||
const readableChannelName = computed(() => {
|
||||
if (props.channelType === 'Channel::Api') {
|
||||
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
|
||||
@ -46,6 +60,9 @@ const readableChannelName = computed(() => {
|
||||
if (props.channelType === 'Channel::TwilioSms') {
|
||||
return twilioChannelName();
|
||||
}
|
||||
if (props.channelType === 'Channel::Whatsapp') {
|
||||
return whatsappChannelName();
|
||||
}
|
||||
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -67,10 +67,14 @@ watchEffect(() => {
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.TITLE')
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.TITLE'
|
||||
)
|
||||
"
|
||||
:header-content="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.SUBTITLE')
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.SUBTITLE'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
@ -90,7 +94,7 @@ watchEffect(() => {
|
||||
<Button :is-loading="loading" @click="setup">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LINK_DEVICE'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.LINK_DEVICE'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
@ -101,7 +105,7 @@ watchEffect(() => {
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LOADING_QRCODE'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.LOADING_QRCODE'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@ -119,7 +123,7 @@ watchEffect(() => {
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.RECONNECTING'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.RECONNECTING'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@ -130,7 +134,7 @@ watchEffect(() => {
|
||||
<p v-if="isSetup" class="text-center">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.CONNECTED'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.CONNECTED'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@ -138,7 +142,7 @@ watchEffect(() => {
|
||||
<Button ghost :is-loading="loading" @click="disconnect">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.DISCONNECT'
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.DISCONNECT'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
@ -9,7 +9,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { isValidURL } from '../../../../../helper/URLHelper';
|
||||
import WhatsappBaileysLinkDeviceModal from '../components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
import WhatsappLinkDeviceModal from '../components/WhatsappLinkDeviceModal.vue';
|
||||
import InboxName from '../../../../../components/widgets/InboxName.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
@ -20,7 +20,7 @@ export default {
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
WhatsappReauthorize,
|
||||
WhatsappBaileysLinkDeviceModal,
|
||||
WhatsappLinkDeviceModal,
|
||||
InboxName,
|
||||
// eslint-disable-next-line vue/no-reserved-component-names
|
||||
Switch,
|
||||
@ -41,17 +41,26 @@ export default {
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
whatsAppProviderUrl: '',
|
||||
showBaileysLinkDeviceModal: false,
|
||||
baileysProviderUrl: '',
|
||||
showLinkDeviceModal: false,
|
||||
markAsRead: true,
|
||||
zapiInstanceId: '',
|
||||
zapiToken: '',
|
||||
zapiClientToken: '',
|
||||
zapiTokenUpdate: '',
|
||||
zapiClientTokenUpdate: '',
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
whatsAppInboxAPIKey: {
|
||||
requiredIf: requiredIf(!this.isAWhatsAppBaileysChannel),
|
||||
requiredIf: requiredIf(
|
||||
!this.isAWhatsAppBaileysChannel && !this.isAWhatsAppZapiChannel
|
||||
),
|
||||
},
|
||||
whatsAppProviderUrl: { isValidURL: value => !value || isValidURL(value) },
|
||||
baileysProviderUrl: { isValidURL: value => !value || isValidURL(value) },
|
||||
zapiTokenUpdate: {},
|
||||
zapiClientTokenUpdate: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -73,8 +82,11 @@ export default {
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.hmacMandatory = this.inbox.hmac_mandatory || false;
|
||||
this.whatsAppProviderUrl = this.inbox.provider_config?.provider_url ?? '';
|
||||
this.baileysProviderUrl = this.inbox.provider_config?.provider_url ?? '';
|
||||
this.markAsRead = this.inbox.provider_config?.mark_as_read ?? true;
|
||||
this.zapiInstanceId = this.inbox.provider_config?.instance_id ?? '';
|
||||
this.zapiToken = this.inbox.provider_config?.token ?? '';
|
||||
this.zapiClientToken = this.inbox.provider_config?.client_token ?? '';
|
||||
},
|
||||
handleHmacFlag() {
|
||||
this.updateInbox();
|
||||
@ -131,7 +143,7 @@ export default {
|
||||
this.isSyncingTemplates = false;
|
||||
}
|
||||
},
|
||||
async updateWhatsAppProviderUrl() {
|
||||
async updateBaileysProviderUrl() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
@ -139,7 +151,7 @@ export default {
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
provider_url: this.whatsAppProviderUrl,
|
||||
provider_url: this.baileysProviderUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -168,11 +180,47 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
onOpenBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = true;
|
||||
onOpenLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = true;
|
||||
},
|
||||
onCloseBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = false;
|
||||
onCloseLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = false;
|
||||
},
|
||||
async updateZapiToken() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
token: this.zapiTokenUpdate,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async updateZapiClientToken() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
client_token: this.zapiClientTokenUpdate,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -414,10 +462,10 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppBaileysChannel">
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal"
|
||||
:show="showBaileysLinkDeviceModal"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
<WhatsappLinkDeviceModal
|
||||
v-if="showLinkDeviceModal"
|
||||
:show="showLinkDeviceModal"
|
||||
:on-close="onCloseLinkDeviceModal"
|
||||
:inbox="inbox"
|
||||
/>
|
||||
<div class="mx-8">
|
||||
@ -440,7 +488,7 @@ export default {
|
||||
with-phone-number
|
||||
with-provider-connection-status
|
||||
/>
|
||||
<NextButton class="w-fit" @click="onOpenBaileysLinkDeviceModal">
|
||||
<NextButton class="w-fit" @click="onOpenLinkDeviceModal">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
|
||||
@ -459,25 +507,25 @@ export default {
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppProviderUrl"
|
||||
v-model="baileysProviderUrl"
|
||||
type="text"
|
||||
class="flex-1 mr-2 items-center"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_PLACEHOLDER')
|
||||
"
|
||||
@keydown="v$.whatsAppProviderUrl.$touch"
|
||||
@keydown="v$.baileysProviderUrl.$touch"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="
|
||||
v$.whatsAppProviderUrl.$invalid ||
|
||||
whatsAppProviderUrl === inbox.provider_config.provider_url
|
||||
v$.baileysProviderUrl.$invalid ||
|
||||
baileysProviderUrl === inbox.provider_config.provider_url
|
||||
"
|
||||
@click="updateWhatsAppProviderUrl"
|
||||
@click="updateBaileysProviderUrl"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<span v-if="v$.whatsAppProviderUrl.$error" class="text-red-400">
|
||||
<span v-if="v$.baileysProviderUrl.$error" class="text-red-400">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_ERROR') }}
|
||||
</span>
|
||||
</SettingsSection>
|
||||
@ -541,6 +589,125 @@ export default {
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppZapiChannel">
|
||||
<WhatsappLinkDeviceModal
|
||||
v-if="showLinkDeviceModal"
|
||||
:show="showLinkDeviceModal"
|
||||
:on-close="onCloseLinkDeviceModal"
|
||||
:inbox="inbox"
|
||||
/>
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE'
|
||||
)
|
||||
"
|
||||
:sub-title="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<InboxName
|
||||
:inbox="inbox"
|
||||
class="!text-lg !m-0"
|
||||
with-phone-number
|
||||
with-provider-connection-status
|
||||
/>
|
||||
<NextButton class="w-fit" @click="onOpenLinkDeviceModal">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
|
||||
)
|
||||
}}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.instance_id" />
|
||||
</SettingsSection>
|
||||
<template v-if="inbox.provider_config.token">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_SUBHEADER')"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.token" secure />
|
||||
</SettingsSection>
|
||||
</template>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="zapiTokenUpdate"
|
||||
type="password"
|
||||
class="flex-1 mr-2"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="
|
||||
v$.zapiTokenUpdate.$invalid ||
|
||||
(!inbox.provider_config.token && !zapiTokenUpdate) ||
|
||||
zapiTokenUpdate === inbox.provider_config.token
|
||||
"
|
||||
@click="updateZapiToken"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<template v-if="inbox.provider_config.client_token">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.client_token" secure />
|
||||
</SettingsSection>
|
||||
</template>
|
||||
<SettingsSection
|
||||
:title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE')
|
||||
"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="zapiClientTokenUpdate"
|
||||
type="password"
|
||||
class="flex-1 mr-2"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="
|
||||
v$.zapiClientTokenUpdate.$invalid ||
|
||||
(!inbox.provider_config.client_token && !zapiClientTokenUpdate) ||
|
||||
zapiClientTokenUpdate === inbox.provider_config.client_token
|
||||
"
|
||||
@click="updateZapiClientToken"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -95,6 +95,12 @@ export default {
|
||||
this.whatsAppAPIProvider === 'baileys'
|
||||
);
|
||||
},
|
||||
isAWhatsAppZapiChannel() {
|
||||
return (
|
||||
this.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
this.whatsAppAPIProvider === 'zapi'
|
||||
);
|
||||
},
|
||||
chatAdditionalAttributes() {
|
||||
const { additional_attributes: additionalAttributes } = this.chat || {};
|
||||
return additionalAttributes || {};
|
||||
|
||||
@ -277,6 +277,14 @@ describe('inboxMixin', () => {
|
||||
expect(wrapper.vm.isAWhatsAppBaileysChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppZapiChannel returns true if channel type is WhatsApp and provider is zapi', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'zapi',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppZapiChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
|
||||
32
app/jobs/channels/whatsapp/zapi_qr_code_job.rb
Normal file
32
app/jobs/channels/whatsapp/zapi_qr_code_job.rb
Normal file
@ -0,0 +1,32 @@
|
||||
class Channels::Whatsapp::ZapiQrCodeJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(whatsapp_channel, attempt = 1)
|
||||
return if attempt == 1 && whatsapp_channel.provider_connection['connection'] != 'close'
|
||||
return if attempt > 1 && whatsapp_channel.provider_connection['connection'] != 'connecting'
|
||||
|
||||
if attempt > 3
|
||||
whatsapp_channel.update_provider_connection!(connection: 'close')
|
||||
return
|
||||
end
|
||||
|
||||
fetch_and_update_qr_code(whatsapp_channel)
|
||||
self.class.set(wait: 30.seconds).perform_later(whatsapp_channel, attempt + 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_and_update_qr_code(whatsapp_channel)
|
||||
service = Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: whatsapp_channel)
|
||||
qr_code = service.qr_code_image
|
||||
|
||||
return if qr_code.blank?
|
||||
# NOTE: Avoid race condition.
|
||||
return if whatsapp_channel.reload.provider_connection['connection'] == 'open'
|
||||
|
||||
whatsapp_channel.update_provider_connection!(
|
||||
connection: 'connecting',
|
||||
qr_data_url: qr_code
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -13,6 +13,8 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
when 'baileys'
|
||||
Whatsapp::IncomingMessageBaileysService.new(inbox: channel.inbox, params: params).perform
|
||||
when 'zapi'
|
||||
Whatsapp::IncomingMessageZapiService.new(inbox: channel.inbox, params: params).perform
|
||||
else
|
||||
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_channel_whatsapp_baileys_connection (provider_connection) WHERE ((provider)::text = 'baileys'::text) USING gin
|
||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin # rubocop:disable Layout/LineLength
|
||||
#
|
||||
|
||||
class Channel::Whatsapp < ApplicationRecord
|
||||
@ -27,7 +27,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||
|
||||
# default at the moment is 360dialog lets change later.
|
||||
PROVIDERS = %w[default whatsapp_cloud baileys].freeze
|
||||
PROVIDERS = %w[default whatsapp_cloud baileys zapi].freeze
|
||||
before_validation :ensure_webhook_verify_token
|
||||
|
||||
validates :provider, inclusion: { in: PROVIDERS }
|
||||
@ -39,7 +39,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
after_create :sync_templates
|
||||
before_destroy :teardown_webhooks
|
||||
|
||||
before_destroy :disconnect_channel_provider, if: -> { provider == 'baileys' }
|
||||
before_destroy :disconnect_channel_provider, if: -> { provider_service.respond_to?(:disconnect_channel_provider) }
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@ -51,6 +51,8 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
||||
when 'baileys'
|
||||
Whatsapp::Providers::WhatsappBaileysService.new(whatsapp_channel: self)
|
||||
when 'zapi'
|
||||
Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: self)
|
||||
else
|
||||
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
|
||||
end
|
||||
|
||||
@ -111,11 +111,12 @@ class Message < ApplicationRecord
|
||||
# [:data] : Used for structured content types such as voice_call
|
||||
# [:is_reaction] : Used to denote if the message is a reaction and differentiate it from a simple reply message
|
||||
# [:is_edited, :previous_content] : Used to indicated edited message and previous content (before edit)
|
||||
# [:zapi_args] : Used to pass additional arguments specific to Z-API WhatsApp provider
|
||||
|
||||
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
|
||||
:external_created_at, :story_sender, :story_id, :external_error,
|
||||
:translations, :in_reply_to_external_id, :is_unsupported, :data,
|
||||
:is_reaction, :is_edited, :previous_content], coder: JSON
|
||||
:is_reaction, :is_edited, :previous_content, :zapi_args], coder: JSON
|
||||
|
||||
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ class Conversations::MessageWindowService
|
||||
when 'Channel::Instagram'
|
||||
instagram_messaging_window
|
||||
when 'Channel::Whatsapp'
|
||||
return if @conversation.inbox.channel.provider == 'baileys'
|
||||
return if %w[baileys zapi].include?(@conversation.inbox.channel.provider)
|
||||
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
|
||||
19
app/services/whatsapp/incoming_message_zapi_service.rb
Normal file
19
app/services/whatsapp/incoming_message_zapi_service.rb
Normal file
@ -0,0 +1,19 @@
|
||||
class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseService
|
||||
include Whatsapp::ZapiHandlers::ConnectedCallback
|
||||
include Whatsapp::ZapiHandlers::DisconnectedCallback
|
||||
include Whatsapp::ZapiHandlers::ReceivedCallback
|
||||
include Whatsapp::ZapiHandlers::DeliveryCallback
|
||||
include Whatsapp::ZapiHandlers::MessageStatusCallback
|
||||
|
||||
def perform
|
||||
return if processed_params[:type].blank?
|
||||
|
||||
event_prefix = processed_params[:type].underscore
|
||||
method_name = "process_#{event_prefix}"
|
||||
if respond_to?(method_name, true)
|
||||
send(method_name)
|
||||
else
|
||||
Rails.logger.warn "Z-API unsupported event: #{processed_params.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
273
app/services/whatsapp/providers/whatsapp_zapi_service.rb
Normal file
273
app/services/whatsapp/providers/whatsapp_zapi_service.rb
Normal file
@ -0,0 +1,273 @@
|
||||
class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseService # rubocop:disable Metrics/ClassLength
|
||||
class ProviderUnavailableError < StandardError; end
|
||||
|
||||
API_BASE_PATH = 'https://api.z-api.io'.freeze
|
||||
|
||||
def send_template(phone_number, template_info); end
|
||||
|
||||
def sync_templates; end
|
||||
|
||||
def send_message(phone_number, message)
|
||||
phone = phone_number.delete('+')
|
||||
params = message.content_attributes[:zapi_args].presence || {}
|
||||
|
||||
params[:messageId] = message.in_reply_to_external_id if message.in_reply_to_external_id.present?
|
||||
|
||||
if message.content_attributes[:is_reaction]
|
||||
send_reaction_message(phone, message, **params)
|
||||
elsif message.attachments.present?
|
||||
handle_message_with_attachment(message, phone, **params)
|
||||
elsif message.content.present?
|
||||
send_text_message(phone, message, **params)
|
||||
else
|
||||
message.update!(is_unsupported: true)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
response = HTTParty.get(
|
||||
"#{api_instance_path_with_token}/status",
|
||||
headers: api_headers
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def setup_channel_provider
|
||||
response = HTTParty.put(
|
||||
"#{api_instance_path_with_token}/update-every-webhooks",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
value: whatsapp_channel.inbox.callback_webhook_url,
|
||||
notifySentByMe: true
|
||||
}.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
Channels::Whatsapp::ZapiQrCodeJob.perform_later(whatsapp_channel) if whatsapp_channel.provider_connection['connection'] == 'close'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def disconnect_channel_provider
|
||||
response = HTTParty.get(
|
||||
"#{api_instance_path_with_token}/disconnect",
|
||||
headers: api_headers
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def qr_code_image
|
||||
response = HTTParty.get(
|
||||
"#{api_instance_path_with_token}/qr-code/image",
|
||||
headers: api_headers
|
||||
)
|
||||
|
||||
if response.parsed_response['connected']
|
||||
whatsapp_channel.update_provider_connection!(connection: 'open')
|
||||
return
|
||||
end
|
||||
|
||||
return unless process_response(response)
|
||||
|
||||
response.parsed_response['value']
|
||||
end
|
||||
|
||||
def read_messages(messages, phone_number:, **)
|
||||
# NOTE: Z-API will handle marking previous messages as read.
|
||||
last_message = messages.last
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/read-message",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone_number.delete('+'),
|
||||
messageId: last_message.source_id
|
||||
}.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def on_whatsapp(phone_number)
|
||||
response = HTTParty.get(
|
||||
"#{api_instance_path_with_token}/phone-exists/#{phone_number.delete('+')}",
|
||||
headers: api_headers
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response || { 'exists' => false, 'phone' => nil, 'lid' => nil }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_instance_path
|
||||
"#{API_BASE_PATH}/instances/#{whatsapp_channel.provider_config['instance_id']}"
|
||||
end
|
||||
|
||||
def api_instance_path_with_token
|
||||
"#{api_instance_path}/token/#{whatsapp_channel.provider_config['token']}"
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{ 'Content-Type' => 'application/json', 'Client-Token' => whatsapp_channel.provider_config['client_token'] }
|
||||
end
|
||||
|
||||
def process_response(response)
|
||||
Rails.logger.error response.body unless response.success?
|
||||
response.success?
|
||||
end
|
||||
|
||||
def send_text_message(phone, message, **params)
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-text",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
message: message.content,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
unless process_response(response)
|
||||
message.update!(status: :failed, external_error: response.parsed_response&.dig('error'))
|
||||
raise ProviderUnavailableError
|
||||
end
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
|
||||
def handle_message_with_attachment(message, phone, **params)
|
||||
attachment = message.attachments.first
|
||||
|
||||
if attachment.file.byte_size > max_size(attachment)
|
||||
message.update!(status: :failed, external_error: 'File too large')
|
||||
return
|
||||
end
|
||||
|
||||
base64_data = Base64.strict_encode64(attachment.file.download)
|
||||
buffer = "data:#{attachment.file.content_type};base64,#{base64_data}"
|
||||
|
||||
case attachment.file_type
|
||||
when 'image'
|
||||
send_image_message(phone, message, buffer, **params)
|
||||
when 'audio'
|
||||
send_audio_message(phone, message, buffer, **params)
|
||||
when 'file'
|
||||
send_document_message(phone, message, attachment, buffer, **params)
|
||||
when 'video'
|
||||
send_video_message(phone, message, buffer, **params)
|
||||
end
|
||||
end
|
||||
|
||||
def max_size(attachment)
|
||||
case attachment.file_type
|
||||
when 'image'
|
||||
5.megabytes
|
||||
when 'audio', 'video'
|
||||
16.megabytes
|
||||
else
|
||||
100.megabytes
|
||||
end
|
||||
end
|
||||
|
||||
def send_image_message(phone, message, buffer, **params)
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-image",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
image: buffer,
|
||||
caption: message.content,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
|
||||
def send_audio_message(phone, _message, buffer, **params)
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-audio",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
audio: buffer,
|
||||
waveform: true,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
|
||||
def send_document_message(phone, message, attachment, buffer, **params)
|
||||
file_extension = File.extname(attachment.file.filename.to_s).delete('.')
|
||||
if file_extension.blank?
|
||||
Rails.logger.warn "Missing file extension for attachment: #{attachment.id}"
|
||||
file_extension = 'bin'
|
||||
end
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-document/#{file_extension}",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
document: buffer,
|
||||
fileName: attachment.file.filename.to_s,
|
||||
caption: message.content,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
|
||||
def send_video_message(phone, message, buffer, **params)
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-video",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
video: buffer,
|
||||
caption: message.content,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
|
||||
def send_reaction_message(phone, message, **params)
|
||||
response = HTTParty.post(
|
||||
"#{api_instance_path_with_token}/send-reaction",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
phone: phone,
|
||||
reaction: message.content,
|
||||
messageId: message.in_reply_to_external_id,
|
||||
**params
|
||||
}.compact.to_json
|
||||
)
|
||||
|
||||
raise ProviderUnavailableError unless process_response(response)
|
||||
|
||||
response.parsed_response&.dig('messageId')
|
||||
end
|
||||
end
|
||||
20
app/services/whatsapp/zapi_handlers/connected_callback.rb
Normal file
20
app/services/whatsapp/zapi_handlers/connected_callback.rb
Normal file
@ -0,0 +1,20 @@
|
||||
module Whatsapp::ZapiHandlers::ConnectedCallback
|
||||
include Whatsapp::ZapiHandlers::Helpers
|
||||
|
||||
private
|
||||
|
||||
def process_connected_callback
|
||||
expected_phone_number = inbox.channel.phone_number.delete('+')
|
||||
received_phone_number = processed_params[:phone]
|
||||
|
||||
if normalised_brazil_mobile_number(expected_phone_number) != normalised_brazil_mobile_number(received_phone_number)
|
||||
inbox.channel.update_provider_connection!(connection: 'close',
|
||||
error: I18n.t('errors.inboxes.channel.provider_connection.wrong_phone_number'))
|
||||
|
||||
inbox.channel.disconnect_channel_provider
|
||||
return
|
||||
end
|
||||
|
||||
inbox.channel.update_provider_connection!(connection: 'open')
|
||||
end
|
||||
end
|
||||
17
app/services/whatsapp/zapi_handlers/delivery_callback.rb
Normal file
17
app/services/whatsapp/zapi_handlers/delivery_callback.rb
Normal file
@ -0,0 +1,17 @@
|
||||
module Whatsapp::ZapiHandlers::DeliveryCallback
|
||||
include Whatsapp::ZapiHandlers::Helpers
|
||||
|
||||
private
|
||||
|
||||
def process_delivery_callback
|
||||
message = inbox.messages.find_by(source_id: processed_params[:messageId])
|
||||
return unless message
|
||||
|
||||
external_created_at = processed_params[:momment] / 1000
|
||||
if processed_params[:error].present?
|
||||
message.update!(status: :failed, external_error: processed_params[:error], external_created_at: external_created_at)
|
||||
else
|
||||
message.update!(status: :delivered, external_created_at: external_created_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,9 @@
|
||||
module Whatsapp::ZapiHandlers::DisconnectedCallback
|
||||
include Whatsapp::ZapiHandlers::Helpers
|
||||
|
||||
private
|
||||
|
||||
def process_disconnected_callback
|
||||
inbox.channel.update_provider_connection!(connection: 'close')
|
||||
end
|
||||
end
|
||||
28
app/services/whatsapp/zapi_handlers/helpers.rb
Normal file
28
app/services/whatsapp/zapi_handlers/helpers.rb
Normal file
@ -0,0 +1,28 @@
|
||||
module Whatsapp::ZapiHandlers::Helpers
|
||||
include Whatsapp::IncomingMessageServiceHelpers
|
||||
|
||||
private
|
||||
|
||||
def raw_message_id
|
||||
@raw_message[:isEdit] ? @raw_message[:editMessageId] : @raw_message[:messageId]
|
||||
end
|
||||
|
||||
def incoming_message?
|
||||
!@raw_message[:fromMe]
|
||||
end
|
||||
|
||||
def cache_message_source_id_in_redis
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id)
|
||||
Redis::Alfred.setex(key, true)
|
||||
end
|
||||
|
||||
def clear_message_source_id_from_redis
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id)
|
||||
Redis::Alfred.delete(key)
|
||||
end
|
||||
|
||||
def message_under_process?
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id)
|
||||
Redis::Alfred.get(key)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,40 @@
|
||||
module Whatsapp::ZapiHandlers::MessageStatusCallback
|
||||
include Whatsapp::ZapiHandlers::Helpers
|
||||
|
||||
private
|
||||
|
||||
def process_message_status_callback
|
||||
status = map_zapi_status_to_chatwoot(processed_params[:status])
|
||||
return unless status
|
||||
|
||||
processed_params[:ids].each do |message_id|
|
||||
message = inbox.messages.find_by(source_id: message_id)
|
||||
next unless message
|
||||
|
||||
message.update!(status: status) if status_transition_allowed?(message, status.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def map_zapi_status_to_chatwoot(zapi_status)
|
||||
case zapi_status.upcase
|
||||
when 'SENT'
|
||||
:sent
|
||||
when 'DELIVERED', 'RECEIVED'
|
||||
:delivered
|
||||
when 'READ', 'READ_BY_ME', 'PLAYED'
|
||||
:read
|
||||
when 'FAILED'
|
||||
:failed
|
||||
else
|
||||
Rails.logger.warn "Unknown ZAPI status: #{zapi_status}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def status_transition_allowed?(message, new_status)
|
||||
return false if message.status == 'read'
|
||||
return false if message.status == 'delivered' && new_status == 'sent'
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
208
app/services/whatsapp/zapi_handlers/received_callback.rb
Normal file
208
app/services/whatsapp/zapi_handlers/received_callback.rb
Normal file
@ -0,0 +1,208 @@
|
||||
module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/ModuleLength
|
||||
include Whatsapp::ZapiHandlers::Helpers
|
||||
|
||||
private
|
||||
|
||||
def process_received_callback
|
||||
@raw_message = processed_params
|
||||
@message = nil
|
||||
@contact_inbox = nil
|
||||
@contact = nil
|
||||
|
||||
return unless should_process_message?
|
||||
return if find_message_by_source_id(raw_message_id) || message_under_process?
|
||||
|
||||
cache_message_source_id_in_redis
|
||||
|
||||
return handle_edited_message if @raw_message[:isEdit]
|
||||
|
||||
set_contact
|
||||
|
||||
unless @contact
|
||||
Rails.logger.warn "Contact not found for message: #{raw_message_id}"
|
||||
return
|
||||
end
|
||||
|
||||
set_conversation
|
||||
handle_create_message
|
||||
ensure
|
||||
clear_message_source_id_from_redis
|
||||
end
|
||||
|
||||
def should_process_message?
|
||||
!@raw_message[:isGroup] &&
|
||||
!@raw_message[:isNewsletter] &&
|
||||
!@raw_message[:broadcast] &&
|
||||
!@raw_message[:isStatusReply]
|
||||
end
|
||||
|
||||
def message_type # rubocop:disable Metrics/CyclomaticComplexity
|
||||
return 'reaction' if @raw_message.key?(:reaction)
|
||||
return 'text' if @raw_message.key?(:text)
|
||||
return 'image' if @raw_message.key?(:image)
|
||||
return 'sticker' if @raw_message.key?(:sticker)
|
||||
return 'audio' if @raw_message.key?(:audio)
|
||||
return 'video' if @raw_message.key?(:video)
|
||||
return 'file' if @raw_message.key?(:document)
|
||||
|
||||
'unsupported'
|
||||
end
|
||||
|
||||
def message_content
|
||||
case message_type
|
||||
when 'text'
|
||||
@raw_message.dig(:text, :message)
|
||||
when 'image'
|
||||
@raw_message.dig(:image, :caption)
|
||||
when 'video'
|
||||
@raw_message.dig(:video, :caption)
|
||||
when 'file'
|
||||
@raw_message.dig(:document, :fileName)
|
||||
when 'reaction'
|
||||
@raw_message.dig(:reaction, :value)
|
||||
end
|
||||
end
|
||||
|
||||
def contact_name
|
||||
@raw_message[:chatName] || @raw_message[:senderName] || @raw_message[:phone]
|
||||
end
|
||||
|
||||
def set_contact
|
||||
push_name = contact_name
|
||||
source_id = @raw_message[:phone]
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: push_name, phone_number: "+#{source_id}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
@contact.update!(name: push_name) if @contact.name == source_id
|
||||
try_update_contact_avatar
|
||||
end
|
||||
|
||||
def try_update_contact_avatar
|
||||
avatar_url = @raw_message[:senderPhoto] || @raw_message[:photo]
|
||||
return unless avatar_url.present? && avatar_url.start_with?('http')
|
||||
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, avatar_url)
|
||||
end
|
||||
|
||||
def handle_create_message
|
||||
create_message(attach_media: %w[image sticker file video audio].include?(message_type))
|
||||
end
|
||||
|
||||
def create_message(attach_media: false)
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content,
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
source_id: raw_message_id,
|
||||
sender: incoming_message? ? @contact : @inbox.account.account_users.first.user,
|
||||
sender_type: incoming_message? ? 'Contact' : 'User',
|
||||
message_type: incoming_message? ? :incoming : :outgoing,
|
||||
content_attributes: message_content_attributes
|
||||
)
|
||||
|
||||
handle_attach_media if attach_media
|
||||
|
||||
@message.save!
|
||||
|
||||
inbox.channel.received_messages([@message], @conversation) if incoming_message?
|
||||
end
|
||||
|
||||
def message_content_attributes
|
||||
type = message_type
|
||||
content_attributes = { external_created_at: @raw_message[:momment] / 1000 }
|
||||
|
||||
if type == 'reaction'
|
||||
content_attributes[:in_reply_to_external_id] = @raw_message.dig(:reaction, :referencedMessage, :messageId)
|
||||
content_attributes[:is_reaction] = true
|
||||
elsif type == 'unsupported'
|
||||
content_attributes[:is_unsupported] = true
|
||||
end
|
||||
|
||||
content_attributes[:in_reply_to_external_id] = @raw_message[:referenceMessageId] if @raw_message[:referenceMessageId].present?
|
||||
|
||||
content_attributes
|
||||
end
|
||||
|
||||
def handle_attach_media
|
||||
attachment_file = download_attachment_file
|
||||
|
||||
attachment = @message.attachments.build(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type.to_s,
|
||||
file: { io: attachment_file, filename: filename, content_type: message_mimetype }
|
||||
)
|
||||
|
||||
attachment.meta = { is_recorded_audio: true } if @raw_message.dig(:audio, :ptt)
|
||||
rescue Down::Error => e
|
||||
@message.update!(is_unsupported: true)
|
||||
Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
|
||||
end
|
||||
|
||||
def download_attachment_file
|
||||
media_url = case message_type
|
||||
when 'image'
|
||||
@raw_message.dig(:image, :imageUrl)
|
||||
when 'sticker'
|
||||
@raw_message.dig(:sticker, :stickerUrl)
|
||||
when 'audio'
|
||||
@raw_message.dig(:audio, :audioUrl)
|
||||
when 'video'
|
||||
@raw_message.dig(:video, :videoUrl)
|
||||
when 'file'
|
||||
@raw_message.dig(:document, :documentUrl)
|
||||
end
|
||||
|
||||
Down.download(media_url)
|
||||
end
|
||||
|
||||
def filename
|
||||
case message_type
|
||||
when 'file'
|
||||
@raw_message.dig(:document, :fileName)
|
||||
else
|
||||
ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
|
||||
"#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
|
||||
end
|
||||
end
|
||||
|
||||
def file_content_type
|
||||
return :image if %w[image sticker].include?(message_type)
|
||||
return :video if message_type == 'video'
|
||||
return :audio if message_type == 'audio'
|
||||
|
||||
:file
|
||||
end
|
||||
|
||||
def message_mimetype
|
||||
case message_type
|
||||
when 'image'
|
||||
@raw_message.dig(:image, :mimeType)
|
||||
when 'sticker'
|
||||
@raw_message.dig(:sticker, :mimeType)
|
||||
when 'video'
|
||||
@raw_message.dig(:video, :mimeType)
|
||||
when 'audio'
|
||||
@raw_message.dig(:audio, :mimeType)
|
||||
when 'file'
|
||||
@raw_message.dig(:document, :mimeType)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_edited_message
|
||||
@message = find_message_by_source_id(@raw_message[:messageId])
|
||||
return unless @message
|
||||
|
||||
@message.update!(
|
||||
content: message_content,
|
||||
is_edited: true,
|
||||
previous_content: @message.content
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -209,3 +209,6 @@
|
||||
display_name: SAML
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: channel_zapi
|
||||
display_name: Z-API Channel
|
||||
enabled: false
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
class RecreateWhatsappChannelProviderConnectionIndex < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
remove_index :channel_whatsapp, name: 'index_channel_whatsapp_baileys_connection', if_exists: true
|
||||
|
||||
add_index :channel_whatsapp, :provider_connection,
|
||||
using: :gin,
|
||||
where: "provider IN ('baileys', 'zapi')",
|
||||
name: 'index_channel_whatsapp_provider_connection',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :channel_whatsapp, name: 'index_channel_whatsapp_provider_connection', if_exists: true
|
||||
|
||||
add_index :channel_whatsapp, :provider_connection,
|
||||
using: :gin,
|
||||
where: "provider = 'baileys'",
|
||||
name: 'index_channel_whatsapp_baileys_connection',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@ -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_09_16_024703) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@ -549,7 +549,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
|
||||
t.datetime "message_templates_last_updated", precision: nil
|
||||
t.jsonb "provider_connection", default: {}
|
||||
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
|
||||
t.index ["provider_connection"], name: "index_channel_whatsapp_baileys_connection", where: "((provider)::text = 'baileys'::text)", using: :gin
|
||||
t.index ["provider_connection"], name: "index_channel_whatsapp_provider_connection", where: "((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[]))", using: :gin
|
||||
end
|
||||
|
||||
create_table "contact_inboxes", force: :cascade do |t|
|
||||
|
||||
98
spec/jobs/channels/whatsapp/zapi_qr_code_job_spec.rb
Normal file
98
spec/jobs/channels/whatsapp/zapi_qr_code_job_spec.rb
Normal file
@ -0,0 +1,98 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channels::Whatsapp::ZapiQrCodeJob do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp,
|
||||
provider: 'zapi',
|
||||
sync_templates: false,
|
||||
validate_provider_config: false,
|
||||
provider_config: {
|
||||
'instance_id' => 'test-instance',
|
||||
'token' => 'test-token',
|
||||
'client_token' => 'test-client-token'
|
||||
})
|
||||
end
|
||||
let(:zapi_service) { instance_double(Whatsapp::Providers::WhatsappZapiService) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::Providers::WhatsappZapiService).to receive(:new)
|
||||
.with(whatsapp_channel: whatsapp_channel)
|
||||
.and_return(zapi_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'returns early on first attempt when connection is not close' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'open')
|
||||
|
||||
expect(zapi_service).not_to receive(:qr_code_image)
|
||||
|
||||
described_class.perform_now(whatsapp_channel, 1)
|
||||
end
|
||||
|
||||
it 'returns early on subsequent attempts when connection is not connecting' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'open')
|
||||
|
||||
expect(zapi_service).not_to receive(:qr_code_image)
|
||||
|
||||
described_class.perform_now(whatsapp_channel, 2)
|
||||
end
|
||||
|
||||
it 'fetches QR code and schedules next attempt on first attempt with close connection' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'close')
|
||||
allow(zapi_service).to receive(:qr_code_image).and_return('data:image/png;base64,test-qr-code')
|
||||
|
||||
expect(described_class).to receive(:set).with(wait: 30.seconds).and_call_original
|
||||
expect do
|
||||
described_class.perform_now(whatsapp_channel, 1)
|
||||
end.to have_enqueued_job(described_class).with(whatsapp_channel, 2)
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('connecting')
|
||||
expect(whatsapp_channel.provider_connection['qr_data_url']).to eq('data:image/png;base64,test-qr-code')
|
||||
end
|
||||
|
||||
it 'fetches QR code and schedules next attempt on subsequent attempts with connecting connection' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'connecting')
|
||||
allow(zapi_service).to receive(:qr_code_image).and_return('data:image/png;base64,updated-qr-code')
|
||||
|
||||
expect(described_class).to receive(:set).with(wait: 30.seconds).and_call_original
|
||||
expect do
|
||||
described_class.perform_now(whatsapp_channel, 2)
|
||||
end.to have_enqueued_job(described_class).with(whatsapp_channel, 3)
|
||||
|
||||
whatsapp_channel.reload
|
||||
expect(whatsapp_channel.provider_connection['connection']).to eq('connecting')
|
||||
expect(whatsapp_channel.provider_connection['qr_data_url']).to eq('data:image/png;base64,updated-qr-code')
|
||||
end
|
||||
|
||||
it 'does not update provider connection when QR code is blank' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'close')
|
||||
allow(zapi_service).to receive(:qr_code_image).and_return(nil)
|
||||
|
||||
expect(whatsapp_channel).not_to receive(:update_provider_connection!)
|
||||
|
||||
described_class.perform_now(whatsapp_channel, 1)
|
||||
end
|
||||
|
||||
it 'avoids race condition when connection becomes open during processing' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'close')
|
||||
allow(zapi_service).to receive(:qr_code_image).and_return('data:image/png;base64,test-qr-code')
|
||||
allow(whatsapp_channel).to receive(:reload).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:provider_connection).and_return({ 'connection' => 'open' })
|
||||
|
||||
expect(whatsapp_channel).not_to receive(:update_provider_connection!)
|
||||
|
||||
described_class.perform_now(whatsapp_channel, 1)
|
||||
end
|
||||
|
||||
it 'sets connection to close when maximum attempts exceeded' do
|
||||
whatsapp_channel.update_provider_connection!(connection: 'connecting')
|
||||
|
||||
expect(zapi_service).not_to receive(:qr_code_image)
|
||||
expect(described_class).not_to receive(:set)
|
||||
|
||||
described_class.perform_now(whatsapp_channel, 4)
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -379,7 +379,7 @@ RSpec.describe Channel::Whatsapp do
|
||||
|
||||
describe 'callbacks' do
|
||||
describe '#disconnect_channel_provider' do
|
||||
context 'when provider is baileys' do
|
||||
context 'when provider implements the method' do
|
||||
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
||||
let(:disconnect_url) { "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}" }
|
||||
|
||||
@ -402,7 +402,7 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provider is not baileys' do
|
||||
context 'when provider does not implement the method' do
|
||||
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
|
||||
|
||||
it 'does not invoke callback' do
|
||||
|
||||
@ -455,6 +455,24 @@ RSpec.describe Conversations::MessageWindowService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on WhatsApp Z-API channels' do
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'zapi', sync_templates: false, validate_provider_config: false) }
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 25.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Web widget channels' do
|
||||
let!(:widget_channel) { create(:channel_widget) }
|
||||
let!(:widget_inbox) { create(:inbox, channel: widget_channel, account: widget_channel.account) }
|
||||
|
||||
794
spec/services/whatsapp/incoming_message_zapi_service_spec.rb
Normal file
794
spec/services/whatsapp/incoming_message_zapi_service_spec.rb
Normal file
@ -0,0 +1,794 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::IncomingMessageZapiService do
|
||||
describe '#perform' do
|
||||
let!(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, provider: 'zapi', validate_provider_config: false, received_messages: false)
|
||||
end
|
||||
let(:inbox) { whatsapp_channel.inbox }
|
||||
|
||||
context 'when type is blank' do
|
||||
it 'does nothing' do
|
||||
params = { type: '' }
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when type is nil' do
|
||||
params = {}
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event type is unsupported' do
|
||||
it 'logs a warning message' do
|
||||
params = { type: 'unsupported_event' }
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(Rails.logger).to have_received(:warn).with(/Z-API unsupported event/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing connected_callback event' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ConnectedCallback',
|
||||
phone: whatsapp_channel.phone_number.delete('+')
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates provider connection to open when phone numbers match' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
|
||||
end
|
||||
|
||||
it 'updates provider connection to close when phone numbers do not match' do
|
||||
params[:phone] = '5511123456789'
|
||||
allow(whatsapp_channel).to receive(:disconnect_channel_provider)
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
|
||||
expect(whatsapp_channel.provider_connection['error']).to eq(I18n.t('errors.inboxes.channel.provider_connection.wrong_phone_number'))
|
||||
expect(whatsapp_channel).to have_received(:disconnect_channel_provider)
|
||||
end
|
||||
|
||||
it 'handles Brazil mobile number normalization' do
|
||||
whatsapp_channel.update!(phone_number: '+5511987654321')
|
||||
params[:phone] = '551187654321' # Without leading digit '9'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing disconnected_callback event' do
|
||||
let(:params) { { type: 'DisconnectedCallback' } }
|
||||
|
||||
it 'updates provider connection to close' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing received_callback event' do
|
||||
let(:contact_phone) { '+5511987654321' }
|
||||
let(:message_id) { 'msg_123' }
|
||||
let(:contact) { create(:contact, phone_number: contact_phone, account: inbox.account) }
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: message_id,
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'chat',
|
||||
text: { message: 'Hello World' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new message when message does not exist' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Hello World')
|
||||
expect(message.source_id).to eq(message_id)
|
||||
expect(message.message_type).to eq('incoming')
|
||||
end
|
||||
|
||||
it 'does not create duplicate messages' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'handles edited messages' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
original_message = Message.last
|
||||
edited_params = params.merge(
|
||||
isEdit: true,
|
||||
text: { message: 'Hello World - Edited' }
|
||||
)
|
||||
|
||||
described_class.new(inbox: inbox, params: edited_params).perform
|
||||
|
||||
expect(original_message.reload.content).to eq('Hello World - Edited')
|
||||
expect(original_message.content_attributes['is_edited']).to be(true)
|
||||
expect(original_message.content_attributes['previous_content']).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'calls channel received_messages method for incoming messages' do
|
||||
allow(inbox.channel).to receive(:received_messages)
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
message = Message.last
|
||||
conversation = message.conversation
|
||||
expect(inbox.channel).to have_received(:received_messages).with([message], conversation)
|
||||
end
|
||||
|
||||
context 'when processing image message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'img_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'image',
|
||||
image: {
|
||||
caption: 'Check this image',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
mimeType: 'image/jpeg'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/image.jpg')
|
||||
.to_return(status: 200, body: 'fake image data', headers: { 'Content-Type' => 'image/jpeg' })
|
||||
end
|
||||
|
||||
it 'creates message with image attachment' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Check this image')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing audio message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'audio_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'audio',
|
||||
audio: {
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
mimeType: 'audio/mpeg'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/audio.mp3')
|
||||
.to_return(status: 200, body: 'fake audio data', headers: { 'Content-Type' => 'audio/mpeg' })
|
||||
end
|
||||
|
||||
it 'creates message with audio attachment' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('audio')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing video message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'video_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'video',
|
||||
video: {
|
||||
caption: 'Check this video',
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
mimeType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/video.mp4')
|
||||
.to_return(status: 200, body: 'fake video data', headers: { 'Content-Type' => 'video/mp4' })
|
||||
end
|
||||
|
||||
it 'creates message with video attachment' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Check this video')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing document message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'doc_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'document',
|
||||
document: {
|
||||
caption: 'Important document',
|
||||
documentUrl: 'https://example.com/document.pdf',
|
||||
fileName: 'document.pdf',
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/document.pdf')
|
||||
.to_return(status: 200, body: 'fake pdf data', headers: { 'Content-Type' => 'application/pdf' })
|
||||
end
|
||||
|
||||
it 'creates message with document attachment' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('document.pdf')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('file')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing unsupported message type' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'unsupported_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
messageType: 'unsupported',
|
||||
data: 'some unsupported data'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates message marked as unsupported' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to be_blank
|
||||
expect(message.is_unsupported).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing reaction message' do
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511987654321') }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, contact_inbox: contact_inbox) }
|
||||
let!(:original_message) { create(:message, inbox: inbox, conversation: conversation, source_id: 'original_123') } # rubocop:disable RSpec/LetSetup
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'reaction_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
reaction: {
|
||||
value: '👍',
|
||||
referencedMessage: { messageId: 'original_123' }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates reaction message' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('👍')
|
||||
expect(message.content_attributes[:is_reaction]).to be(true)
|
||||
expect(message.content_attributes[:in_reply_to_external_id]).to eq('original_123')
|
||||
end
|
||||
|
||||
it 'creates empty reaction message' do
|
||||
params[:reaction][:value] = ''
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('')
|
||||
expect(message.content_attributes[:is_reaction]).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing sticker message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'sticker_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
sticker: {
|
||||
stickerUrl: 'https://example.com/sticker.webp',
|
||||
mimeType: 'image/webp'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/sticker.webp')
|
||||
.to_return(status: 200, body: 'fake sticker data', headers: { 'Content-Type' => 'image/webp' })
|
||||
end
|
||||
|
||||
it 'creates message with sticker attachment' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing outgoing message' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'outgoing_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: true,
|
||||
text: { message: 'Outgoing message' }
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
create(:account_user, account: inbox.account)
|
||||
end
|
||||
|
||||
it 'creates outgoing message' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Outgoing message')
|
||||
expect(message.message_type).to eq('outgoing')
|
||||
end
|
||||
|
||||
it 'does not call channel received_messages method for outgoing messages' do
|
||||
allow(inbox.channel).to receive(:received_messages)
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(inbox.channel).not_to have_received(:received_messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling duplicated events' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'duplicate_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
text: { message: 'Duplicated event' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'does not create message if it is already being processed' do
|
||||
allow(Redis::Alfred).to receive(:get)
|
||||
.with(format_message_source_key('duplicate_123'))
|
||||
.and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'caches and clears message source id in Redis' do
|
||||
allow(Redis::Alfred).to receive(:setex)
|
||||
allow(Redis::Alfred).to receive(:delete)
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(Redis::Alfred).to have_received(:setex)
|
||||
.with(format_message_source_key('duplicate_123'), true)
|
||||
expect(Redis::Alfred).to have_received(:delete)
|
||||
.with(format_message_source_key('duplicate_123'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment download fails' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'img_fail_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
image: {
|
||||
caption: 'Failed image',
|
||||
imageUrl: 'https://example.com/broken.jpg',
|
||||
mimeType: 'image/jpeg'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Down).to receive(:download).and_raise(Down::ResponseError.new('Download failed'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'creates message marked as unsupported when download fails' do
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.is_unsupported).to be(true)
|
||||
expect(Rails.logger).to have_received(:error).with(/Failed to download attachment/)
|
||||
end
|
||||
|
||||
it 'handles malformed attachment URLs gracefully' do
|
||||
allow(Down).to receive(:download).and_raise(Down::InvalidUrl.new('Invalid URL'))
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.is_unsupported).to be(true)
|
||||
end
|
||||
|
||||
it 'handles network timeout errors' do
|
||||
allow(Down).to receive(:download).and_raise(Down::TimeoutError.new('Download timeout'))
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.is_unsupported).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact name handling' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'name_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654322',
|
||||
fromMe: false,
|
||||
senderName: 'John Doe from Z-API',
|
||||
text: { message: 'Hello with name' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates contact with sender name when provided' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
contact = Contact.last
|
||||
expect(contact.name).to eq('John Doe from Z-API')
|
||||
end
|
||||
|
||||
it 'uses phone number as name when sender name is not provided' do
|
||||
params.delete(:senderName)
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.sender.name).to eq('5511987654322')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message should not be processed' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'filtered_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
text: { message: 'Filtered message' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'does not process group messages' do
|
||||
params[:isGroup] = true
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'does not process newsletter messages' do
|
||||
params[:isNewsletter] = true
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'does not process broadcast messages' do
|
||||
params[:broadcast] = true
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'does not process status reply messages' do
|
||||
params[:isStatusReply] = true
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing attachment with file extensions' do
|
||||
let(:params) do
|
||||
{
|
||||
type: 'ReceivedCallback',
|
||||
messageId: 'ext_123',
|
||||
momment: Time.current.to_i * 1000,
|
||||
phone: '5511987654321',
|
||||
fromMe: false,
|
||||
document: {
|
||||
fileName: 'report.xlsx',
|
||||
documentUrl: 'https://example.com/report.xlsx',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/report.xlsx')
|
||||
.to_return(status: 200, body: 'fake excel data',
|
||||
headers: { 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
end
|
||||
|
||||
it 'preserves original filename and extension' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
message = Message.last
|
||||
attachment = message.attachments.first
|
||||
expect(attachment.file.filename.to_s).to eq('report.xlsx')
|
||||
expect(attachment.file.content_type).to eq('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing delivery_callback event' do
|
||||
let(:message) { create(:message, inbox: inbox, source_id: 'msg_456') }
|
||||
let(:params) do
|
||||
{
|
||||
type: 'DeliveryCallback',
|
||||
messageId: message.source_id,
|
||||
momment: Time.current.to_i * 1000
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates message status to delivered' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message.reload.status).to eq('delivered')
|
||||
expect(message.external_created_at).to eq(params[:momment] / 1000)
|
||||
end
|
||||
|
||||
it 'updates message status to failed when error is present' do
|
||||
params[:error] = 'Message delivery failed'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Message delivery failed')
|
||||
end
|
||||
|
||||
it 'does nothing when message is not found' do
|
||||
params[:messageId] = 'non_existent_message'
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to change(message, :status)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing message_status_callback event' do
|
||||
let(:message1) { create(:message, inbox: inbox, source_id: 'msg_123') }
|
||||
let(:message2) { create(:message, inbox: inbox, source_id: 'msg_456') }
|
||||
let(:params) do
|
||||
{
|
||||
type: 'MessageStatusCallback',
|
||||
ids: [message1.source_id, message2.source_id],
|
||||
status: 'SENT'
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates message status to sent when Z-API status is SENT' do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'updates message status to delivered for DELIVERED status' do
|
||||
params[:status] = 'DELIVERED'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('delivered')
|
||||
expect(message2.reload.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates message status to delivered for RECEIVED status' do
|
||||
params[:status] = 'RECEIVED'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('delivered')
|
||||
expect(message2.reload.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates message status to read for READ status' do
|
||||
params[:status] = 'READ'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('read')
|
||||
expect(message2.reload.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'updates message status to read for READ_BY_ME status' do
|
||||
params[:status] = 'READ_BY_ME'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('read')
|
||||
expect(message2.reload.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'updates message status to read for PLAYED status' do
|
||||
params[:status] = 'PLAYED'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('read')
|
||||
expect(message2.reload.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'does not update status on unknown status and logs warning' do
|
||||
params[:status] = 'UNKNOWN_STATUS'
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to(change { [message1.reload.status, message2.reload.status] })
|
||||
|
||||
expect(Rails.logger).to have_received(:warn).with('Unknown ZAPI status: UNKNOWN_STATUS')
|
||||
end
|
||||
|
||||
context 'when status transition is not allowed' do
|
||||
it 'does not downgrade read message to delivered' do
|
||||
message1.update!(status: 'read')
|
||||
params[:status] = 'DELIVERED'
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to(change { message1.reload.status })
|
||||
end
|
||||
|
||||
it 'does not downgrade read message to sent' do
|
||||
message1.update!(status: 'read')
|
||||
params[:status] = 'SENT'
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to(change { message1.reload.status })
|
||||
|
||||
expect(message1.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'does not downgrade delivered message to sent' do
|
||||
message1.update!(status: 'delivered')
|
||||
params[:status] = 'SENT'
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
end.not_to(change { message1.reload.status })
|
||||
|
||||
expect(message1.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'allows upgrading delivered message to read' do
|
||||
message1.update!(status: 'delivered')
|
||||
params[:status] = 'READ'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'allows upgrading sent message to delivered' do
|
||||
message1.update!(status: 'sent')
|
||||
params[:status] = 'DELIVERED'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'allows upgrading sent message to read' do
|
||||
message1.update!(status: 'sent')
|
||||
params[:status] = 'READ'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'handles mixed status transitions correctly' do
|
||||
message1.update!(status: 'sent')
|
||||
message2.update!(status: 'read')
|
||||
params[:status] = 'DELIVERED'
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(message1.reload.status).to eq('delivered')
|
||||
expect(message2.reload.status).to eq('read')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_message_source_key(message_id)
|
||||
format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id)
|
||||
end
|
||||
end
|
||||
645
spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb
Normal file
645
spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb
Normal file
@ -0,0 +1,645 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::Providers::WhatsappZapiService do
|
||||
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
|
||||
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, provider: 'zapi', validate_provider_config: false).tap do |channel|
|
||||
channel.provider_config = {
|
||||
'instance_id' => 'test_instance',
|
||||
'token' => 'test_token',
|
||||
'client_token' => 'test_client_token'
|
||||
}
|
||||
channel.save!
|
||||
end
|
||||
end
|
||||
let(:message) { create(:message, source_id: 'msg_123', content_attributes: { external_created_at: 123 }) }
|
||||
|
||||
let(:test_send_phone_number) { '551187654321' }
|
||||
let(:api_instance_path) { "#{described_class::API_BASE_PATH}/instances/#{whatsapp_channel.provider_config['instance_id']}" }
|
||||
let(:api_instance_path_with_token) { "#{api_instance_path}/token/#{whatsapp_channel.provider_config['token']}" }
|
||||
|
||||
def stub_headers(extra_headers = {})
|
||||
{
|
||||
'Accept' => '*/*',
|
||||
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||
'Content-Type' => 'application/json',
|
||||
'Client-Token' => 'test_client_token',
|
||||
'User-Agent' => 'Ruby'
|
||||
}.merge(extra_headers)
|
||||
end
|
||||
|
||||
describe '#validate_provider_config?' do
|
||||
context 'when response is successful' do
|
||||
it 'returns true' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/status")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
|
||||
expect(service.validate_provider_config?).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'logs the error and returns false' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/status")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect(service.validate_provider_config?).to be(false)
|
||||
expect(Rails.logger).to have_received(:error).with('error message')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#setup_channel_provider' do
|
||||
context 'when response is successful' do
|
||||
it 'sets up the webhook and returns true' do
|
||||
stub_request(:put, "#{api_instance_path_with_token}/update-every-webhooks")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
value: whatsapp_channel.inbox.callback_webhook_url,
|
||||
notifySentByMe: true
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200)
|
||||
|
||||
expect(service.setup_channel_provider).to be(true)
|
||||
end
|
||||
|
||||
it 'schedules QR code job when connection is closed' do
|
||||
whatsapp_channel.update!(provider_connection: { 'connection' => 'close' })
|
||||
|
||||
stub_request(:put, "#{api_instance_path_with_token}/update-every-webhooks")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200)
|
||||
|
||||
service.setup_channel_provider
|
||||
|
||||
expect(Channels::Whatsapp::ZapiQrCodeJob).to have_been_enqueued.with(whatsapp_channel)
|
||||
end
|
||||
|
||||
it 'does not schedule QR code job when connection is not closed' do
|
||||
whatsapp_channel.update!(provider_connection: { 'connection' => 'open' })
|
||||
|
||||
stub_request(:put, "#{api_instance_path_with_token}/update-every-webhooks")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200)
|
||||
|
||||
service.setup_channel_provider
|
||||
|
||||
expect(Channels::Whatsapp::ZapiQrCodeJob).not_to have_been_enqueued
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'raises ProviderUnavailableError' do
|
||||
stub_request(:put, "#{api_instance_path_with_token}/update-every-webhooks")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
service.setup_channel_provider
|
||||
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disconnect_channel_provider' do
|
||||
context 'when response is successful' do
|
||||
it 'disconnects the whatsapp connection' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/disconnect")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200)
|
||||
|
||||
expect(service.disconnect_channel_provider).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'raises ProviderUnavailableError' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/disconnect")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
service.disconnect_channel_provider
|
||||
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#qr_code_image' do
|
||||
context 'when response indicates connected' do
|
||||
it 'updates provider connection to open and returns nil' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/qr-code/image")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200, body: { connected: true }.to_json)
|
||||
|
||||
expect(whatsapp_channel).to receive(:update_provider_connection!).with(connection: 'open')
|
||||
|
||||
result = service.qr_code_image
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is successful and not connected' do
|
||||
it 'returns the QR code value' do
|
||||
qr_code_value = 'base64_qr_code_data'
|
||||
response_body = { 'value' => qr_code_value }
|
||||
stub_request(:get, "#{api_instance_path_with_token}/qr-code/image")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.qr_code_image
|
||||
|
||||
expect(result).to eq(qr_code_value)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'logs error and returns nil' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/qr-code/image")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
result = service.qr_code_image
|
||||
|
||||
expect(result).to be_nil
|
||||
expect(Rails.logger).to have_received(:error).with('error message')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#read_messages' do
|
||||
let(:messages) { [create(:message), message] }
|
||||
|
||||
context 'when response is successful' do
|
||||
it 'marks messages as read by referencing last message id' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/read-message")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
messageId: message.source_id
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200)
|
||||
|
||||
result = service.read_messages(messages, phone_number: "+#{test_send_phone_number}")
|
||||
|
||||
expect(result).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'raises ProviderUnavailableError' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/read-message")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
service.read_messages(messages, phone_number: "+#{test_send_phone_number}")
|
||||
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#on_whatsapp' do
|
||||
let(:phone_number) { '+123456789' }
|
||||
|
||||
context 'when response is successful' do
|
||||
it 'checks if phone number exists on WhatsApp' do
|
||||
response_body = { 'exists' => true, 'phone' => '123456789', 'lid' => 'some_lid' }
|
||||
stub_request(:get, "#{api_instance_path_with_token}/phone-exists/123456789")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.on_whatsapp(phone_number)
|
||||
|
||||
expect(result).to eq(response_body)
|
||||
end
|
||||
|
||||
it 'returns default response when parsed_response is nil' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/phone-exists/123456789")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 200, body: '')
|
||||
|
||||
result = service.on_whatsapp(phone_number)
|
||||
|
||||
expect(result).to eq({ 'exists' => false, 'phone' => nil, 'lid' => nil })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response is unsuccessful' do
|
||||
it 'raises ProviderUnavailableError' do
|
||||
stub_request(:get, "#{api_instance_path_with_token}/phone-exists/123456789")
|
||||
.with(headers: stub_headers)
|
||||
.to_return(status: 400, body: 'error message', headers: {})
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
service.on_whatsapp(phone_number)
|
||||
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_message' do
|
||||
let(:request_path) { "#{api_instance_path_with_token}/send-text" }
|
||||
let(:result_body) { { 'messageId' => 'msg_123' } }
|
||||
|
||||
context 'when message is unsupported' do
|
||||
it 'updates the message with is_unsupported attribute' do
|
||||
unsupported_message = create(:message, content: nil)
|
||||
|
||||
result = service.send_message(test_send_phone_number, unsupported_message)
|
||||
|
||||
expect(unsupported_message.reload.is_unsupported).to be(true)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a text' do
|
||||
it 'sends the text message' do
|
||||
stub_request(:post, request_path)
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
message: message.content
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
|
||||
it 'includes zapi_args when present' do
|
||||
message.update!(content_attributes: { zapi_args: { delayMessage: 1000 } })
|
||||
|
||||
stub_request(:post, request_path)
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
message: message.content,
|
||||
delayMessage: 1000
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
|
||||
context 'when request is unsuccessful' do
|
||||
it 'updates message status and raises ProviderUnavailableError' do
|
||||
error_response = { 'error' => 'Failed to send message' }
|
||||
stub_request(:post, request_path)
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
message: message.content
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 400, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
service.send_message("+#{test_send_phone_number}", message)
|
||||
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Failed to send message')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an image file' do
|
||||
let(:base64_image) { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' }
|
||||
let(:buffer) { "data:image/png;base64,#{base64_image}" }
|
||||
|
||||
before do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'image',
|
||||
file: {
|
||||
io: StringIO.new(Base64.decode64(base64_image)),
|
||||
filename: 'image.png'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the image message' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-image")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
image: buffer,
|
||||
caption: message.content
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an audio file' do
|
||||
let(:base64_audio) { 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' }
|
||||
|
||||
before do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'audio',
|
||||
file: {
|
||||
io: StringIO.new(Base64.decode64(base64_audio)),
|
||||
filename: 'audio.wav'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the audio message' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-audio")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: hash_including({
|
||||
phone: test_send_phone_number,
|
||||
waveform: true
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a video file' do
|
||||
let(:base64_video) { 'AAABAAIADAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }
|
||||
|
||||
before do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'video',
|
||||
file: {
|
||||
io: StringIO.new(Base64.decode64(base64_video)),
|
||||
filename: 'video.mp4'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the video message' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-video")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: hash_including({
|
||||
phone: test_send_phone_number,
|
||||
caption: message.content
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a document file' do
|
||||
let(:base64_document) { 'JVBERi0xLjQKJcOkw7zDtsOgw7MKMSAwIG9iagp' }
|
||||
|
||||
before do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'file',
|
||||
file: {
|
||||
io: StringIO.new(Base64.decode64(base64_document)),
|
||||
filename: 'document.pdf'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the document message' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-document/pdf")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: hash_including({
|
||||
phone: test_send_phone_number,
|
||||
fileName: 'document.pdf',
|
||||
caption: message.content
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
|
||||
it 'handles missing file extension gracefully' do
|
||||
message.attachments.first.file.blob.update!(filename: 'document')
|
||||
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-document/bin")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: hash_including({
|
||||
phone: test_send_phone_number,
|
||||
fileName: 'document',
|
||||
caption: message.content
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
expect(Rails.logger).to have_received(:warn).with(/Missing file extension/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a reply' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:original_message) { create(:message, source_id: 'original_msg_123', conversation: conversation) }
|
||||
let(:reply_message) do
|
||||
create(:message,
|
||||
content: 'This is a reply',
|
||||
source_id: 'reply_msg_123',
|
||||
conversation: conversation,
|
||||
content_attributes: {
|
||||
external_created_at: 123,
|
||||
in_reply_to: original_message.id
|
||||
})
|
||||
end
|
||||
|
||||
it 'sends the reply message with messageId parameter' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-text")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
message: reply_message.content,
|
||||
messageId: original_message.source_id
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", reply_message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a reaction' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:original_message) { create(:message, source_id: 'original_msg_456', conversation: conversation) }
|
||||
let(:reaction_message) do
|
||||
create(:message,
|
||||
content: '👍',
|
||||
source_id: 'reaction_msg_789',
|
||||
conversation: conversation,
|
||||
content_attributes: {
|
||||
is_reaction: true,
|
||||
external_created_at: 123,
|
||||
in_reply_to: original_message.id
|
||||
})
|
||||
end
|
||||
|
||||
it 'sends the reaction message' do
|
||||
stub_request(:post, "#{api_instance_path_with_token}/send-reaction")
|
||||
.with(
|
||||
headers: stub_headers,
|
||||
body: {
|
||||
phone: test_send_phone_number,
|
||||
reaction: reaction_message.content,
|
||||
messageId: original_message.source_id
|
||||
}.to_json
|
||||
)
|
||||
.to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", reaction_message)
|
||||
|
||||
expect(result).to eq('msg_123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment exceeds size limits' do
|
||||
it 'rejects image files larger than 5MB' do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'image',
|
||||
file: {
|
||||
io: StringIO.new('fake image content'),
|
||||
filename: 'large_image.png'
|
||||
}
|
||||
)
|
||||
|
||||
attachment = message.attachments.first
|
||||
allow(attachment.file).to receive(:byte_size).and_return(6.megabytes)
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('File too large')
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects audio files larger than 16MB' do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'audio',
|
||||
file: {
|
||||
io: StringIO.new('fake audio content'),
|
||||
filename: 'large_audio.mp3'
|
||||
}
|
||||
)
|
||||
|
||||
attachment = message.attachments.first
|
||||
allow(attachment.file).to receive(:byte_size).and_return(17.megabytes)
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('File too large')
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects video files larger than 16MB' do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'video',
|
||||
file: {
|
||||
io: StringIO.new('fake video content'),
|
||||
filename: 'large_video.mp4'
|
||||
}
|
||||
)
|
||||
|
||||
attachment = message.attachments.first
|
||||
allow(attachment.file).to receive(:byte_size).and_return(17.megabytes)
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('File too large')
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects document files larger than 100MB' do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: 'file',
|
||||
file: {
|
||||
io: StringIO.new('fake document content'),
|
||||
filename: 'large_document.pdf'
|
||||
}
|
||||
)
|
||||
|
||||
attachment = message.attachments.first
|
||||
allow(attachment.file).to receive(:byte_size).and_return(101.megabytes)
|
||||
|
||||
result = service.send_message("+#{test_send_phone_number}", message)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('File too large')
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_template' do
|
||||
it 'is not implemented yet (returns nil)' do
|
||||
expect(service.send_template('+123456789', {})).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_templates' do
|
||||
it 'is not implemented yet (returns nil)' do
|
||||
expect(service.sync_templates).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -136,13 +136,19 @@ export const icons = {
|
||||
},
|
||||
baileys: {
|
||||
body: `
|
||||
<circle cx="50" cy="50" r="47" fill="none" stroke="#1DD1A1" stroke-width="3"/>
|
||||
<path d="M28 26 L72 16 L42 54 L42 34 Z" fill="#1DD1A1"/>
|
||||
<path d="M72 74 L28 84 L58 46 L58 66 Z" fill="#1DD1A1"/>
|
||||
<circle cx="50" cy="50" r="47" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<path d="M28 26 L72 16 L42 54 L42 34 Z" fill="currentColor"/>
|
||||
<path d="M72 74 L28 84 L58 46 L58 66 Z" fill="currentColor"/>
|
||||
`,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
zapi: {
|
||||
body: `<path stroke="currentColor"
|
||||
d="M 0.15050214,31.611667 -6.727909e-7,31.2227 0.89864166,27.585988 1.8729551,26.090955 2.8472685,24.595922 15.789266,11.660059 4.1063138,11.40095 4.1544241,11.012276 C 4.2020703,10.627325 4.3819706,9.9942713 5.5949694,5.9429145 5.9390689,4.7936802 6.5411296,3.3109868 6.9329149,2.6479859 L 7.645179,1.4426365 9.5522129,-2.6631488e-6 H 47.999999 L 47.954743,0.90347077 47.909486,1.8069344 46.293465,3.2388989 H 18.637918 l -0.162915,0.26143 -0.162916,0.26143 0.330216,1.0318136 h 23.740162 v 0.4134697 c 0,0.2274083 -0.196756,0.7438048 -0.437239,1.1475694 l -0.437239,0.7340801 -0.542525,0.1444794 c -0.298389,0.079423 -3.834608,0.147858 -7.858122,0.151981 l -7.315596,0.00783 -0.698903,0.4393434 0.08817,0.618481 0.08817,0.6184712 11.496318,0.2591188 v 1.2955455 l -2.331555,1.165981 h -5.153988 l -9.441753,9.256746 0.367355,0.589484 h 14.050945 l -0.218768,0.712554 c -0.120325,0.391915 -0.610257,1.93279 -1.088704,3.424199 l -0.869975,2.711645 -0.791895,1.169408 -0.791895,1.169408 -1.045057,0.584401 -1.045057,0.584391 -28.10287818,0.0078 z"/>`,
|
||||
width: 48,
|
||||
height: 32,
|
||||
},
|
||||
instagram: {
|
||||
body: `<path d="M8.667 0A3.333 3.333 0 0 1 12 3.333v5.334A3.333 3.333 0 0 1 8.667 12H3.333A3.333 3.333 0 0 1 0 8.667V3.333A3.333 3.333 0 0 1 3.333 0zM6 3.333a2.667 2.667 0 1 0 0 5.334 2.667 2.667 0 0 0 0-5.334m0 1.334a1.333 1.333 0 1 1 0 2.666 1.333 1.333 0 0 1 0-2.666m3-2.334a.667.667 0 1 0 0 1.334.667.667 0 0 0 0-1.334" fill="currentColor"/>`,
|
||||
width: 12,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user