feat(zapi): handle contact messages and ignore notifications (#124)
* chore: skip notification messages * feat: contact card messages * fix: notification message handling * chore: reduce code duplication * fix: improve contact name handling in attachment * chore(zapi): promo banner with affiliate link (#126) * chore(zapi): promo banner with affiliate link * chore: remove useless comment * chore(zapi): add note about hardcoded affiliate link * feat: provider.event_received for raw provider events (#127) * chore(zapi): promo banner with affiliate link * chore: remove useless comment * feat: provider.event_received for raw provider events * feat: add provider_event_received handling in webhook listener * feat(baileys): use senderLid as contact identifier (#128) * chore(zapi): promo banner with affiliate link * chore: remove useless comment * feat: provider.event_received for raw provider events * feat(baileys): use senderLid as contact identifier * fix: simplify webhook_data method by removing unnecessary fields
14
app/javascript/dashboard/assets/images/curved-arrow-red.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg fill="#ef4444" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352.2 352.2">
|
||||||
|
<path d="M348.232,100.282c-13.464-32.436-35.496-60.588-45.9-94.86c-1.836-5.508-11.016-7.956-13.464-1.836
|
||||||
|
c-14.688,34.272-36.72,65.484-47.124,101.592c-1.836,6.732,7.344,13.464,12.24,7.344c7.344-9.18,15.912-16.524,24.479-25.092
|
||||||
|
c-1.224,52.632,0,105.264-9.18,157.284c-4.896,28.152-11.628,59.977-31.824,81.396c-24.479,25.704-55.08,2.448-68.544-21.42
|
||||||
|
c-11.628-20.809-31.823-110.772-72.215-79.561c-23.868,18.36-29.988,43.452-37.332,70.992c-1.836,7.956-4.896,15.3-8.568,22.032
|
||||||
|
c-14.076,26.316-32.436-16.524-33.048-26.928c-1.224-20.809,4.896-42.229,9.792-62.424c1.836-6.12-7.344-8.568-9.792-2.448
|
||||||
|
c-11.016,28.764-26.316,77.724,0,102.815c23.256,21.42,42.84,7.345,52.02-17.748c6.12-16.523,29.376-108.323,56.304-65.483
|
||||||
|
c17.748,28.151,22.644,61.812,44.064,88.128c15.3,18.359,42.84,22.644,64.26,13.464c25.704-11.628,36.72-45.9,43.452-70.38
|
||||||
|
c16.523-61.2,16.523-127.296,14.688-190.332c14.688,9.792,31.212,18.972,47.736,25.092
|
||||||
|
C347.008,113.746,350.681,105.178,348.232,100.282z M268.672,78.25c7.956-17.136,17.748-34.272,26.316-51.408
|
||||||
|
c9.18,21.42,20.808,40.392,31.824,61.2c-12.853-7.956-25.092-17.136-39.168-18.972c-3.061-0.612-5.509,1.224-6.732,3.672
|
||||||
|
C276.628,73.354,272.345,75.19,268.672,78.25z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
126
app/javascript/dashboard/components-next/banner/PromoBanner.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'info',
|
||||||
|
validator: value => ['info', 'success', 'warning'].includes(value),
|
||||||
|
},
|
||||||
|
ctaText: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
ctaLink: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
ctaExternal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showIcon: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
logoSrc: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
logoAlt: {
|
||||||
|
type: String,
|
||||||
|
default: 'Logo',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ctaClick']);
|
||||||
|
|
||||||
|
const variantClasses = computed(() => {
|
||||||
|
const variants = {
|
||||||
|
info: {
|
||||||
|
container: 'bg-woot-50 border-woot-200',
|
||||||
|
icon: 'i-lucide-info text-woot-600',
|
||||||
|
text: 'text-woot-700',
|
||||||
|
description: 'text-woot-600',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
container: 'bg-green-50 border-green-200',
|
||||||
|
icon: 'i-lucide-sparkles text-green-600',
|
||||||
|
text: 'text-green-700',
|
||||||
|
description: 'text-green-600',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: 'bg-yellow-50 border-yellow-200',
|
||||||
|
icon: 'i-lucide-alert-circle text-yellow-600',
|
||||||
|
text: 'text-yellow-700',
|
||||||
|
description: 'text-yellow-600',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return variants[props.variant];
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCtaClick = () => {
|
||||||
|
emit('ctaClick');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative flex items-start gap-3 p-4 rounded-lg border"
|
||||||
|
:class="variantClasses.container"
|
||||||
|
>
|
||||||
|
<div v-if="logoSrc || showIcon" class="flex-shrink-0 mt-0.5">
|
||||||
|
<img
|
||||||
|
v-if="logoSrc"
|
||||||
|
:src="logoSrc"
|
||||||
|
:alt="logoAlt"
|
||||||
|
class="w-8 h-8 object-contain"
|
||||||
|
/>
|
||||||
|
<i v-else class="w-5 h-5" :class="variantClasses.icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-sm font-semibold mb-1" :class="variantClasses.text">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm leading-relaxed" :class="variantClasses.description">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="ctaText" class="mt-3">
|
||||||
|
<a
|
||||||
|
v-if="ctaLink"
|
||||||
|
:href="ctaLink"
|
||||||
|
:target="ctaExternal ? '_blank' : '_self'"
|
||||||
|
:rel="ctaExternal ? 'noopener noreferrer' : undefined"
|
||||||
|
class="inline-block"
|
||||||
|
>
|
||||||
|
<NextButton
|
||||||
|
sm
|
||||||
|
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ ctaText }}
|
||||||
|
</NextButton>
|
||||||
|
</a>
|
||||||
|
<NextButton
|
||||||
|
v-else
|
||||||
|
sm
|
||||||
|
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||||
|
@click="handleCtaClick"
|
||||||
|
>
|
||||||
|
{{ ctaText }}
|
||||||
|
</NextButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -237,7 +237,12 @@
|
|||||||
},
|
},
|
||||||
"SELECT_PROVIDER": {
|
"SELECT_PROVIDER": {
|
||||||
"TITLE": "Select your API provider",
|
"TITLE": "Select your API provider",
|
||||||
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
|
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials.",
|
||||||
|
"ZAPI_PROMO": {
|
||||||
|
"TITLE": "Looking for a reliable WhatsApp solution?",
|
||||||
|
"DESCRIPTION": "Z-API offers superior stability compared to Baileys and is much simpler to set up than Cloud or Twilio - no complex configuration required. Perfect for businesses that want to get started quickly.",
|
||||||
|
"CTA": "Use Z-API"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"INBOX_NAME": {
|
"INBOX_NAME": {
|
||||||
"LABEL": "Inbox Name",
|
"LABEL": "Inbox Name",
|
||||||
@ -342,6 +347,18 @@
|
|||||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||||
"MANUAL_LINK_TEXT": "manual setup flow"
|
"MANUAL_LINK_TEXT": "manual setup flow"
|
||||||
},
|
},
|
||||||
|
"ZAPI_PROMO": {
|
||||||
|
"SWITCH_BANNER": {
|
||||||
|
"TITLE": "Consider switching to Z-API for easier setup",
|
||||||
|
"DESCRIPTION": "Z-API provides a more stable connection than Baileys and requires less configuration than Cloud/Twilio. Switch to a hassle-free WhatsApp integration.",
|
||||||
|
"CTA": "Switch to Z-API"
|
||||||
|
},
|
||||||
|
"SETUP_BANNER": {
|
||||||
|
"TITLE": "Get 10% off your Z-API subscription",
|
||||||
|
"DESCRIPTION": "Create your Z-API account using our affiliate link and receive 10% off. Simple setup, reliable connections, and great support.",
|
||||||
|
"CTA": "Create Z-API Account"
|
||||||
|
}
|
||||||
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,8 @@
|
|||||||
"CONTACT_CREATED": "Contact created",
|
"CONTACT_CREATED": "Contact created",
|
||||||
"CONTACT_UPDATED": "Contact updated",
|
"CONTACT_UPDATED": "Contact updated",
|
||||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||||
|
"PROVIDER_EVENT_RECEIVED": "Provider Event Received"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
|
|||||||
@ -237,7 +237,12 @@
|
|||||||
},
|
},
|
||||||
"SELECT_PROVIDER": {
|
"SELECT_PROVIDER": {
|
||||||
"TITLE": "Selecione seu provedor de API",
|
"TITLE": "Selecione seu provedor de API",
|
||||||
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta."
|
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta.",
|
||||||
|
"ZAPI_PROMO": {
|
||||||
|
"TITLE": "Procurando uma solução WhatsApp confiável?",
|
||||||
|
"DESCRIPTION": "Z-API oferece estabilidade superior comparado ao Baileys e é muito mais simples de configurar que Cloud ou Twilio - sem necessidade de configuração complexa. Perfeito para empresas que querem começar rapidamente.",
|
||||||
|
"CTA": "Usar Z-API"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"INBOX_NAME": {
|
"INBOX_NAME": {
|
||||||
"LABEL": "Nome da Caixa de Entrada",
|
"LABEL": "Nome da Caixa de Entrada",
|
||||||
@ -342,6 +347,18 @@
|
|||||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||||
"MANUAL_LINK_TEXT": "manual setup flow"
|
"MANUAL_LINK_TEXT": "manual setup flow"
|
||||||
},
|
},
|
||||||
|
"ZAPI_PROMO": {
|
||||||
|
"SWITCH_BANNER": {
|
||||||
|
"TITLE": "Considere mudar para Z-API para configuração mais fácil",
|
||||||
|
"DESCRIPTION": "Z-API fornece uma conexão mais estável que Baileys e requer menos configuração que Cloud/Twilio. Mude para uma integração WhatsApp sem complicações.",
|
||||||
|
"CTA": "Mudar para Z-API"
|
||||||
|
},
|
||||||
|
"SETUP_BANNER": {
|
||||||
|
"TITLE": "Ganhe 10% de desconto na sua assinatura Z-API",
|
||||||
|
"DESCRIPTION": "Crie sua conta Z-API usando nosso link de afiliado e receba 10% de desconto. Configuração simples, conexões confiáveis e ótimo suporte.",
|
||||||
|
"CTA": "Criar Conta Z-API"
|
||||||
|
}
|
||||||
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
|
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,8 @@
|
|||||||
"CONTACT_CREATED": "Contato criado",
|
"CONTACT_CREATED": "Contato criado",
|
||||||
"CONTACT_UPDATED": "Contato atualizado",
|
"CONTACT_UPDATED": "Contato atualizado",
|
||||||
"CONVERSATION_TYPING_ON": "Status de Digitação ativado",
|
"CONVERSATION_TYPING_ON": "Status de Digitação ativado",
|
||||||
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado"
|
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado",
|
||||||
|
"PROVIDER_EVENT_RECEIVED": "Evento do Provedor Recebido"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
|
|||||||
@ -11,10 +11,14 @@ import { isValidURL } from '../../../../../helper/URLHelper';
|
|||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
|
||||||
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isFeatureFlagEnabled } = usePolicy();
|
||||||
|
|
||||||
const inboxName = ref('');
|
const inboxName = ref('');
|
||||||
const phoneNumber = ref('');
|
const phoneNumber = ref('');
|
||||||
@ -83,10 +87,35 @@ const createChannel = async () => {
|
|||||||
const setShowAdvancedOptions = () => {
|
const setShowAdvancedOptions = () => {
|
||||||
showAdvancedOptions.value = true;
|
showAdvancedOptions.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const switchToZapi = () => {
|
||||||
|
router.push({
|
||||||
|
name: router.currentRoute.value.name,
|
||||||
|
params: router.currentRoute.value.params,
|
||||||
|
query: { provider: 'zapi' },
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||||
|
<div
|
||||||
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.CHANNEL_ZAPI)"
|
||||||
|
class="w-full mb-6"
|
||||||
|
>
|
||||||
|
<PromoBanner
|
||||||
|
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
|
||||||
|
"
|
||||||
|
variant="info"
|
||||||
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
|
||||||
|
logo-alt="Z-API"
|
||||||
|
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
|
||||||
|
@cta-click="switchToZapi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||||
<label :class="{ error: v$.inboxName.$error }">
|
<label :class="{ error: v$.inboxName.$error }">
|
||||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||||
|
|||||||
@ -7,10 +7,13 @@ import router from '../../../../index';
|
|||||||
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
|
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
NextButton,
|
NextButton,
|
||||||
|
PromoBanner,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
return { v$: useVuelidate() };
|
return { v$: useVuelidate() };
|
||||||
@ -25,7 +28,17 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
|
...mapGetters({
|
||||||
|
uiFlags: 'inboxes/getUIFlags',
|
||||||
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
isZapiEnabled() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.CHANNEL_ZAPI
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
validations: {
|
validations: {
|
||||||
inboxName: { required },
|
inboxName: { required },
|
||||||
@ -72,12 +85,33 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
switchToZapi() {
|
||||||
|
router.push({
|
||||||
|
name: this.$route.name,
|
||||||
|
params: this.$route.params,
|
||||||
|
query: { provider: 'zapi' },
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
|
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
|
||||||
|
<div v-if="isZapiEnabled" class="mb-6">
|
||||||
|
<PromoBanner
|
||||||
|
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
|
||||||
|
"
|
||||||
|
variant="info"
|
||||||
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
|
||||||
|
logo-alt="Z-API"
|
||||||
|
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
|
||||||
|
@cta-click="switchToZapi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
<label :class="{ error: v$.inboxName.$error }">
|
<label :class="{ error: v$.inboxName.$error }">
|
||||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||||
|
|||||||
@ -6,12 +6,15 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import router from '../../../../index';
|
import router from '../../../../index';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
|
||||||
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
||||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
NextButton,
|
NextButton,
|
||||||
|
PromoBanner,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
type: {
|
type: {
|
||||||
@ -38,10 +41,21 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
uiFlags: 'inboxes/getUIFlags',
|
uiFlags: 'inboxes/getUIFlags',
|
||||||
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
}),
|
}),
|
||||||
authTokeni18nKey() {
|
authTokeni18nKey() {
|
||||||
return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN';
|
return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN';
|
||||||
},
|
},
|
||||||
|
isWhatsApp() {
|
||||||
|
return this.type === 'whatsapp';
|
||||||
|
},
|
||||||
|
isZapiEnabled() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.CHANNEL_ZAPI
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
validations() {
|
validations() {
|
||||||
let validations = {
|
let validations = {
|
||||||
@ -112,12 +126,33 @@ export default {
|
|||||||
useAlert(errorMessage);
|
useAlert(errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
switchToZapi() {
|
||||||
|
router.push({
|
||||||
|
name: this.$route.name,
|
||||||
|
params: this.$route.params,
|
||||||
|
query: { provider: 'zapi' },
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
|
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
|
||||||
|
<div v-if="isWhatsApp && isZapiEnabled" class="mb-6">
|
||||||
|
<PromoBanner
|
||||||
|
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
|
||||||
|
"
|
||||||
|
variant="info"
|
||||||
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
|
||||||
|
logo-alt="Z-API"
|
||||||
|
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
|
||||||
|
@cta-click="switchToZapi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
<label :class="{ error: v$.channelName.$error }">
|
<label :class="{ error: v$.channelName.$error }">
|
||||||
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }}
|
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
|||||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||||
import BaileysWhatsapp from './BaileysWhatsapp.vue';
|
import BaileysWhatsapp from './BaileysWhatsapp.vue';
|
||||||
import ZapiWhatsapp from './ZapiWhatsapp.vue';
|
import ZapiWhatsapp from './ZapiWhatsapp.vue';
|
||||||
|
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
@ -117,6 +118,32 @@ const handleManualLinkClick = () => {
|
|||||||
@click="selectProvider(provider.key)"
|
@click="selectProvider(provider.key)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.CHANNEL_ZAPI)"
|
||||||
|
class="mt-6 relative overflow-visible"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="~dashboard/assets/images/curved-arrow-red.svg"
|
||||||
|
alt=""
|
||||||
|
class="absolute -top-12 right-4 w-20 h-20 pointer-events-none z-10"
|
||||||
|
/>
|
||||||
|
<PromoBanner
|
||||||
|
:title="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.TITLE')
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.DESCRIPTION')
|
||||||
|
"
|
||||||
|
variant="success"
|
||||||
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
|
||||||
|
logo-alt="Z-API"
|
||||||
|
:cta-text="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.CTA')
|
||||||
|
"
|
||||||
|
@cta-click="selectProvider(PROVIDER_TYPES.ZAPI)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="showConfiguration">
|
<div v-else-if="showConfiguration">
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { required } from '@vuelidate/validators';
|
|||||||
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@ -22,6 +23,10 @@ const clientToken = ref('');
|
|||||||
|
|
||||||
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
||||||
|
|
||||||
|
// NOTE: Affiliate link is left intentionally hardcoded.
|
||||||
|
const zapiAffiliateUrl =
|
||||||
|
'https://app.z-api.io/app/auth/new-account?afilliate=3E0B31343E6CB0297B567AC1D8277FBB';
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
inboxName: { required },
|
inboxName: { required },
|
||||||
phoneNumber: { required, isPhoneE164OrEmpty },
|
phoneNumber: { required, isPhoneE164OrEmpty },
|
||||||
@ -74,6 +79,21 @@ const createChannel = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||||
|
<div class="w-full mb-6">
|
||||||
|
<PromoBanner
|
||||||
|
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.TITLE')"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.DESCRIPTION')
|
||||||
|
"
|
||||||
|
variant="success"
|
||||||
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
|
||||||
|
logo-alt="Z-API"
|
||||||
|
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.CTA')"
|
||||||
|
cta-external
|
||||||
|
:cta-link="zapiAffiliateUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||||
<label :class="{ error: v$.inboxName.$error }">
|
<label :class="{ error: v$.inboxName.$error }">
|
||||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
|
|||||||
'contact_updated',
|
'contact_updated',
|
||||||
'conversation_typing_on',
|
'conversation_typing_on',
|
||||||
'conversation_typing_off',
|
'conversation_typing_off',
|
||||||
|
'provider_event_received',
|
||||||
];
|
];
|
||||||
|
|
||||||
const localhostUrl = value => {
|
const localhostUrl = value => {
|
||||||
|
|||||||
@ -91,6 +91,19 @@ class WebhookListener < BaseListener
|
|||||||
handle_typing_status(__method__.to_s, event)
|
handle_typing_status(__method__.to_s, event)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def provider_event_received(event)
|
||||||
|
inbox, account = extract_inbox_and_account(event)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: __method__.to_s,
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: account.webhook_data,
|
||||||
|
provider_event: event.data[:event],
|
||||||
|
provider_event_data: event.data[:payload]
|
||||||
|
}
|
||||||
|
deliver_account_webhooks(payload, account)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def handle_typing_status(event_name, event)
|
def handle_typing_status(event_name, event)
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class Webhook < ApplicationRecord
|
|||||||
|
|
||||||
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
||||||
message_created message_updated webwidget_triggered inbox_created inbox_updated
|
message_created message_updated webwidget_triggered inbox_created inbox_updated
|
||||||
conversation_typing_on conversation_typing_off].freeze
|
conversation_typing_on conversation_typing_off provider_event_received].freeze
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,10 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
|
|||||||
@raw_message[:key][:id]
|
@raw_message[:key][:id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sender_lid
|
||||||
|
@raw_message[:key][:senderLid]
|
||||||
|
end
|
||||||
|
|
||||||
def incoming?
|
def incoming?
|
||||||
!@raw_message[:key][:fromMe]
|
!@raw_message[:key][:fromMe]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -45,19 +45,28 @@ module Whatsapp::BaileysHandlers::MessagesUpsert
|
|||||||
push_name = contact_name
|
push_name = contact_name
|
||||||
source_id = phone_number_from_jid
|
source_id = phone_number_from_jid
|
||||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||||
# FIXME: update the source_id to complete jid in future
|
# FIXME: update the source_id to use sender LID
|
||||||
source_id: source_id,
|
source_id: source_id,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
contact_attributes: { name: push_name, phone_number: "+#{source_id}" }
|
contact_attributes: { name: push_name, phone_number: "+#{source_id}", identifier: sender_lid }
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
@contact_inbox = contact_inbox
|
@contact_inbox = contact_inbox
|
||||||
@contact = contact_inbox.contact
|
@contact = contact_inbox.contact
|
||||||
|
|
||||||
@contact.update!(name: push_name) if @contact.name == source_id
|
@contact.update!(name: push_name) if @contact.name == source_id
|
||||||
|
# NOTE: Backwards compatibility for previous contacts created without identifier.
|
||||||
|
# Should be removed in the distant future, since all contacts should be created with identifier.
|
||||||
|
update_contact_identifier
|
||||||
try_update_contact_avatar
|
try_update_contact_avatar
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_contact_identifier
|
||||||
|
return if @contact.identifier.present?
|
||||||
|
|
||||||
|
@contact.update!(identifier: sender_lid)
|
||||||
|
end
|
||||||
|
|
||||||
def handle_create_message
|
def handle_create_message
|
||||||
create_message(attach_media: %w[image file video audio sticker].include?(message_type))
|
create_message(attach_media: %w[image file video audio sticker].include?(message_type))
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseService
|
class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseService
|
||||||
|
include Events::Types
|
||||||
include Whatsapp::BaileysHandlers::ConnectionUpdate
|
include Whatsapp::BaileysHandlers::ConnectionUpdate
|
||||||
include Whatsapp::BaileysHandlers::MessagesUpsert
|
include Whatsapp::BaileysHandlers::MessagesUpsert
|
||||||
include Whatsapp::BaileysHandlers::MessagesUpdate
|
include Whatsapp::BaileysHandlers::MessagesUpdate
|
||||||
|
|
||||||
class InvalidWebhookVerifyToken < StandardError; end
|
class InvalidWebhookVerifyToken < StandardError; end
|
||||||
|
|
||||||
def perform
|
def perform # rubocop:disable Metrics/AbcSize
|
||||||
raise InvalidWebhookVerifyToken if processed_params[:webhookVerifyToken] != inbox.channel.provider_config['webhook_verify_token']
|
raise InvalidWebhookVerifyToken if processed_params[:webhookVerifyToken] != inbox.channel.provider_config['webhook_verify_token']
|
||||||
return if processed_params[:event].blank? || processed_params[:data].blank?
|
return if processed_params[:event].blank? || processed_params[:data].blank?
|
||||||
|
|
||||||
|
Rails.configuration.dispatcher.dispatch(PROVIDER_EVENT_RECEIVED, Time.zone.now, inbox: inbox, event: processed_params[:event],
|
||||||
|
payload: processed_params[:data])
|
||||||
|
|
||||||
event_prefix = processed_params[:event].gsub(/[\.-]/, '_')
|
event_prefix = processed_params[:event].gsub(/[\.-]/, '_')
|
||||||
method_name = "process_#{event_prefix}"
|
method_name = "process_#{event_prefix}"
|
||||||
if respond_to?(method_name, true)
|
if respond_to?(method_name, true)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseService
|
class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseService
|
||||||
|
include Events::Types
|
||||||
include Whatsapp::ZapiHandlers::ConnectedCallback
|
include Whatsapp::ZapiHandlers::ConnectedCallback
|
||||||
include Whatsapp::ZapiHandlers::DisconnectedCallback
|
include Whatsapp::ZapiHandlers::DisconnectedCallback
|
||||||
include Whatsapp::ZapiHandlers::ReceivedCallback
|
include Whatsapp::ZapiHandlers::ReceivedCallback
|
||||||
@ -8,6 +9,9 @@ class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseServic
|
|||||||
def perform
|
def perform
|
||||||
return if processed_params[:type].blank?
|
return if processed_params[:type].blank?
|
||||||
|
|
||||||
|
Rails.configuration.dispatcher.dispatch(PROVIDER_EVENT_RECEIVED, Time.zone.now, inbox: inbox, event: processed_params[:type],
|
||||||
|
payload: processed_params)
|
||||||
|
|
||||||
event_prefix = processed_params[:type].underscore
|
event_prefix = processed_params[:type].underscore
|
||||||
method_name = "process_#{event_prefix}"
|
method_name = "process_#{event_prefix}"
|
||||||
if respond_to?(method_name, true)
|
if respond_to?(method_name, true)
|
||||||
|
|||||||
@ -33,17 +33,19 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
!@raw_message[:isGroup] &&
|
!@raw_message[:isGroup] &&
|
||||||
!@raw_message[:isNewsletter] &&
|
!@raw_message[:isNewsletter] &&
|
||||||
!@raw_message[:broadcast] &&
|
!@raw_message[:broadcast] &&
|
||||||
!@raw_message[:isStatusReply]
|
!@raw_message[:isStatusReply] &&
|
||||||
|
!@raw_message.key?(:notification)
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_type # rubocop:disable Metrics/CyclomaticComplexity
|
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
||||||
return 'reaction' if @raw_message.key?(:reaction)
|
|
||||||
return 'text' if @raw_message.key?(:text)
|
return 'text' if @raw_message.key?(:text)
|
||||||
|
return 'reaction' if @raw_message.key?(:reaction)
|
||||||
|
return 'audio' if @raw_message.key?(:audio)
|
||||||
return 'image' if @raw_message.key?(:image)
|
return 'image' if @raw_message.key?(:image)
|
||||||
return 'sticker' if @raw_message.key?(:sticker)
|
return 'sticker' if @raw_message.key?(:sticker)
|
||||||
return 'audio' if @raw_message.key?(:audio)
|
|
||||||
return 'video' if @raw_message.key?(:video)
|
return 'video' if @raw_message.key?(:video)
|
||||||
return 'file' if @raw_message.key?(:document)
|
return 'file' if @raw_message.key?(:document)
|
||||||
|
return 'contact' if @raw_message.key?(:contact)
|
||||||
|
|
||||||
'unsupported'
|
'unsupported'
|
||||||
end
|
end
|
||||||
@ -60,6 +62,8 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
@raw_message.dig(:document, :fileName)
|
@raw_message.dig(:document, :fileName)
|
||||||
when 'reaction'
|
when 'reaction'
|
||||||
@raw_message.dig(:reaction, :value)
|
@raw_message.dig(:reaction, :value)
|
||||||
|
when 'contact'
|
||||||
|
@raw_message.dig(:contact, :displayName)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -102,10 +106,35 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_create_message
|
def handle_create_message
|
||||||
create_message(attach_media: %w[image sticker file video audio].include?(message_type))
|
if message_type == 'contact'
|
||||||
|
create_contact_message
|
||||||
|
else
|
||||||
|
create_message(attach_media: %w[image sticker file video audio].include?(message_type))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_contact_message
|
||||||
|
contact_data = @raw_message[:contact]
|
||||||
|
phones = contact_data[:phones] || []
|
||||||
|
phones = ['Phone number is not available'] if phones.blank?
|
||||||
|
|
||||||
|
phones.each do |phone|
|
||||||
|
build_message
|
||||||
|
attach_contact(phone, contact_data)
|
||||||
|
@message.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
notify_channel_of_received_message
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_message(attach_media: false)
|
def create_message(attach_media: false)
|
||||||
|
build_message
|
||||||
|
handle_attach_media if attach_media
|
||||||
|
@message.save!
|
||||||
|
notify_channel_of_received_message
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_message
|
||||||
@message = @conversation.messages.build(
|
@message = @conversation.messages.build(
|
||||||
content: message_content,
|
content: message_content,
|
||||||
account_id: @inbox.account_id,
|
account_id: @inbox.account_id,
|
||||||
@ -116,11 +145,9 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
message_type: incoming_message? ? :incoming : :outgoing,
|
message_type: incoming_message? ? :incoming : :outgoing,
|
||||||
content_attributes: message_content_attributes
|
content_attributes: message_content_attributes
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
handle_attach_media if attach_media
|
def notify_channel_of_received_message
|
||||||
|
|
||||||
@message.save!
|
|
||||||
|
|
||||||
inbox.channel.received_messages([@message], @conversation) if incoming_message?
|
inbox.channel.received_messages([@message], @conversation) if incoming_message?
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -140,6 +167,20 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
content_attributes
|
content_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attach_contact(phone, contact_data)
|
||||||
|
name_parts = contact_data[:displayName]&.split || []
|
||||||
|
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: :contact,
|
||||||
|
fallback_title: phone.to_s,
|
||||||
|
meta: {
|
||||||
|
firstName: name_parts.first,
|
||||||
|
lastName: name_parts.drop(1).join(' ')
|
||||||
|
}.compact_blank
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def handle_attach_media
|
def handle_attach_media
|
||||||
attachment_file = download_attachment_file
|
attachment_file = download_attachment_file
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ module Events::Types
|
|||||||
|
|
||||||
# channel events
|
# channel events
|
||||||
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
|
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
|
||||||
|
PROVIDER_EVENT_RECEIVED = 'provider.event_received'
|
||||||
|
|
||||||
# conversation events
|
# conversation events
|
||||||
CONVERSATION_CREATED = 'conversation.created'
|
CONVERSATION_CREATED = 'conversation.created'
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/images/dashboard/channels/z-api/z-api-dual.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/images/dashboard/channels/z-api/z-api-white.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
@ -378,4 +378,70 @@ describe WebhookListener do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#provider_event_received' do
|
||||||
|
let(:event_name) { :'provider_event.received' }
|
||||||
|
let(:provider_event_data) { { message_id: '123', status: 'delivered' } }
|
||||||
|
let!(:provider_event) do
|
||||||
|
Events::Base.new(event_name, Time.zone.now, inbox: inbox, event: 'message.status', payload: provider_event_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when webhook is not configured' do
|
||||||
|
it 'does not trigger webhook' do
|
||||||
|
expect(WebhookJob).not_to receive(:perform_later)
|
||||||
|
listener.provider_event_received(provider_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when webhook is configured' do
|
||||||
|
it 'triggers webhook' do
|
||||||
|
# inbox: nil is needed because the factory defaults inbox_id to 1, and the deliver_account_webhooks
|
||||||
|
# method filters webhooks when payload[:inbox] is present and webhook.inbox_id doesn't match
|
||||||
|
webhook = create(:webhook, account: account, inbox: nil, subscriptions: ['provider_event_received'])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: 'provider_event_received',
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: account.webhook_data,
|
||||||
|
provider_event: 'message.status',
|
||||||
|
provider_event_data: provider_event_data
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
|
||||||
|
listener.provider_event_received(provider_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when webhook is configured and event is not subscribed' do
|
||||||
|
it 'does not trigger webhook' do
|
||||||
|
create(:webhook, account: account, inbox: nil, subscriptions: ['conversation_created'])
|
||||||
|
expect(WebhookJob).not_to receive(:perform_later)
|
||||||
|
listener.provider_event_received(provider_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when webhook has an inbox filter' do
|
||||||
|
it 'triggers webhook if inbox matches' do
|
||||||
|
webhook = create(:webhook, account: account, inbox: inbox, subscriptions: ['provider_event_received'])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: 'provider_event_received',
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: account.webhook_data,
|
||||||
|
provider_event: 'message.status',
|
||||||
|
provider_event_data: provider_event_data
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
|
||||||
|
listener.provider_event_received(provider_event)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not trigger webhook if inbox does not match' do
|
||||||
|
another_inbox = create(:inbox, account: account)
|
||||||
|
create(:webhook, account: account, inbox: another_inbox, subscriptions: ['provider_event_received'])
|
||||||
|
expect(WebhookJob).not_to receive(:perform_later)
|
||||||
|
listener.provider_event_received(provider_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -69,6 +69,25 @@ describe Whatsapp::IncomingMessageBaileysService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'dispatches the provider.event_received event' do
|
||||||
|
params = {
|
||||||
|
webhookVerifyToken: webhook_verify_token,
|
||||||
|
event: 'some.event',
|
||||||
|
data: 'some-data'
|
||||||
|
}
|
||||||
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||||
|
Events::Types::PROVIDER_EVENT_RECEIVED,
|
||||||
|
kind_of(Time),
|
||||||
|
inbox: inbox,
|
||||||
|
event: params[:event],
|
||||||
|
payload: params[:data]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
context 'when processing connection.update event' do
|
context 'when processing connection.update event' do
|
||||||
let(:base_params) { { webhookVerifyToken: webhook_verify_token, event: 'connection.update' } }
|
let(:base_params) { { webhookVerifyToken: webhook_verify_token, event: 'connection.update' } }
|
||||||
|
|
||||||
@ -152,7 +171,7 @@ describe Whatsapp::IncomingMessageBaileysService do
|
|||||||
let(:timestamp) { Time.current.to_i }
|
let(:timestamp) { Time.current.to_i }
|
||||||
let(:raw_message) do
|
let(:raw_message) do
|
||||||
{
|
{
|
||||||
key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false },
|
key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false, senderLid: '12345678@lid' },
|
||||||
pushName: 'John Doe',
|
pushName: 'John Doe',
|
||||||
messageTimestamp: timestamp,
|
messageTimestamp: timestamp,
|
||||||
message: { conversation: 'Hello from Baileys' }
|
message: { conversation: 'Hello from Baileys' }
|
||||||
@ -766,6 +785,34 @@ describe Whatsapp::IncomingMessageBaileysService do
|
|||||||
|
|
||||||
expect(inbox.conversations).to be_empty
|
expect(inbox.conversations).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when handling senderLid field' do
|
||||||
|
it 'creates contact with identifier from senderLid' do
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
contact = inbox.contacts.last
|
||||||
|
|
||||||
|
expect(contact.identifier).to eq('12345678@lid')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates contact identifier for existing contacts without identifier' do
|
||||||
|
contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: nil)
|
||||||
|
create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678')
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(contact.reload.identifier).to eq('12345678@lid')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update contact identifier if already present' do
|
||||||
|
contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: 'existing@lid')
|
||||||
|
create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678')
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(contact.reload.identifier).to eq('existing@lid')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when processing messages.update event' do
|
context 'when processing messages.update event' do
|
||||||
|
|||||||
@ -35,5 +35,20 @@ describe Whatsapp::IncomingMessageZapiService do
|
|||||||
expect(Rails.logger).to have_received(:warn).with(/Z-API unsupported event/)
|
expect(Rails.logger).to have_received(:warn).with(/Z-API unsupported event/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'dispatches the provider.event_received event' do
|
||||||
|
params = { type: 'some_event' }
|
||||||
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||||
|
Events::Types::PROVIDER_EVENT_RECEIVED,
|
||||||
|
kind_of(Time),
|
||||||
|
inbox: inbox,
|
||||||
|
event: params[:type],
|
||||||
|
payload: params
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -455,6 +455,14 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|||||||
end.not_to change(Message, :count)
|
end.not_to change(Message, :count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not process messages with notification key' do
|
||||||
|
notification_params = params.merge(notification: 'REVOKE')
|
||||||
|
|
||||||
|
expect do
|
||||||
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: notification_params).perform
|
||||||
|
end.not_to change(Message, :count)
|
||||||
|
end
|
||||||
|
|
||||||
it 'handles edited messages' do
|
it 'handles edited messages' do
|
||||||
service.perform
|
service.perform
|
||||||
original_message = Message.last
|
original_message = Message.last
|
||||||
@ -951,6 +959,206 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|||||||
expect(attachment.file.content_type).to eq('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
expect(attachment.file.content_type).to eq('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when processing contact message' do
|
||||||
|
context 'with single phone number' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
type: 'ReceivedCallback',
|
||||||
|
messageId: 'contact_123',
|
||||||
|
momment: Time.current.to_i * 1000,
|
||||||
|
phone: '1234567890',
|
||||||
|
chatLid: '1234567890@lid',
|
||||||
|
fromMe: false,
|
||||||
|
contact: {
|
||||||
|
displayName: 'Test Contact Name',
|
||||||
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Test Contact Name\nTEL;type=CELL:+1111111111\nEND:VCARD",
|
||||||
|
phones: ['1111111111']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a message with contact attachment' do
|
||||||
|
expect do
|
||||||
|
service.perform
|
||||||
|
end.to change(Message, :count).by(1)
|
||||||
|
|
||||||
|
message = Message.last
|
||||||
|
expect(message.content).to eq('Test Contact Name')
|
||||||
|
expect(message.attachments.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates contact attachment with correct file_type' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachment = Message.last.attachments.first
|
||||||
|
expect(attachment.file_type).to eq('contact')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores phone number in fallback_title' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachment = Message.last.attachments.first
|
||||||
|
expect(attachment.fallback_title).to eq('1111111111')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts firstName and lastName from displayName' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachment = Message.last.attachments.first
|
||||||
|
expect(attachment.meta['firstName']).to eq('Test')
|
||||||
|
expect(attachment.meta['lastName']).to eq('Contact Name')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multiple phone numbers' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
type: 'ReceivedCallback',
|
||||||
|
messageId: 'contact_456',
|
||||||
|
momment: Time.current.to_i * 1000,
|
||||||
|
phone: '1234567890',
|
||||||
|
chatLid: '1234567890@lid',
|
||||||
|
fromMe: false,
|
||||||
|
contact: {
|
||||||
|
displayName: 'Sample User',
|
||||||
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Sample User\nTEL;type=CELL:+2222222222\nTEL;type=WORK:+3333333333\nEND:VCARD",
|
||||||
|
phones: %w[2222222222 3333333333]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a message for each phone number' do
|
||||||
|
expect do
|
||||||
|
service.perform
|
||||||
|
end.to change(Message, :count).by(2)
|
||||||
|
|
||||||
|
messages = Message.last(2)
|
||||||
|
expect(messages.all? { |m| m.content == 'Sample User' }).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates an attachment for each phone number' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
messages = Message.last(2)
|
||||||
|
expect(messages[0].attachments.count).to eq(1)
|
||||||
|
expect(messages[1].attachments.count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores different phone numbers in fallback_title' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachments = Message.last(2).map { |m| m.attachments.first }
|
||||||
|
expect(attachments[0].fallback_title).to eq('2222222222')
|
||||||
|
expect(attachments[1].fallback_title).to eq('3333333333')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the same source_id for all messages' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
messages = Message.last(2)
|
||||||
|
expect(messages.map(&:source_id).uniq).to eq(['contact_456'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no phone numbers' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
type: 'ReceivedCallback',
|
||||||
|
messageId: 'contact_789',
|
||||||
|
momment: Time.current.to_i * 1000,
|
||||||
|
phone: '1234567890',
|
||||||
|
chatLid: '1234567890@lid',
|
||||||
|
fromMe: false,
|
||||||
|
contact: {
|
||||||
|
displayName: 'Test User',
|
||||||
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Test User\nEND:VCARD",
|
||||||
|
phones: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates message with fallback text' do
|
||||||
|
expect do
|
||||||
|
service.perform
|
||||||
|
end.to change(Message, :count).by(1)
|
||||||
|
|
||||||
|
message = Message.last
|
||||||
|
expect(message.content).to eq('Test User')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates attachment with "Phone number is not available" as fallback_title' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachment = Message.last.attachments.first
|
||||||
|
expect(attachment.fallback_title).to eq('Phone number is not available')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with single-word name' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
type: 'ReceivedCallback',
|
||||||
|
messageId: 'contact_single',
|
||||||
|
momment: Time.current.to_i * 1000,
|
||||||
|
phone: '1234567890',
|
||||||
|
chatLid: '1234567890@lid',
|
||||||
|
fromMe: false,
|
||||||
|
contact: {
|
||||||
|
displayName: 'SingleName',
|
||||||
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:SingleName\nTEL:+4444444444\nEND:VCARD",
|
||||||
|
phones: ['4444444444']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts firstName only when no last name' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
attachment = Message.last.attachments.first
|
||||||
|
expect(attachment.meta['firstName']).to eq('SingleName')
|
||||||
|
expect(attachment.meta.key?('lastName')).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when contact message comes from user (outgoing)' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
type: 'ReceivedCallback',
|
||||||
|
messageId: 'contact_outgoing',
|
||||||
|
momment: Time.current.to_i * 1000,
|
||||||
|
phone: '1234567890',
|
||||||
|
chatLid: '1234567890@lid',
|
||||||
|
fromMe: true,
|
||||||
|
contact: {
|
||||||
|
displayName: 'Outgoing Contact',
|
||||||
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Outgoing Contact\nTEL:+5555555555\nEND:VCARD",
|
||||||
|
phones: ['5555555555']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:account_user, account: inbox.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates outgoing message' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
message = Message.last
|
||||||
|
expect(message.message_type).to eq('outgoing')
|
||||||
|
expect(message.sender_type).to eq('User')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates contact attachment for outgoing message' do
|
||||||
|
service.perform
|
||||||
|
|
||||||
|
message = Message.last
|
||||||
|
expect(message.attachments.count).to eq(1)
|
||||||
|
expect(message.attachments.first.file_type).to eq('contact')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||