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
This commit is contained in:
Gabriel Jablonski 2025-10-26 10:48:06 -03:00 committed by GitHub
parent f12b77c550
commit c234023a4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 753 additions and 19 deletions

View 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

View 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>

View File

@ -237,7 +237,12 @@
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
"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": {
"LABEL": "Inbox Name",
@ -342,6 +347,18 @@
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} 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": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}

View File

@ -43,7 +43,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"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": {

View File

@ -237,7 +237,12 @@
},
"SELECT_PROVIDER": {
"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": {
"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 youre a tech provider onboarding your own number, please use the {link} 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": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
}

View File

@ -43,7 +43,8 @@
"CONTACT_CREATED": "Contato criado",
"CONTACT_UPDATED": "Contato atualizado",
"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": {

View File

@ -11,10 +11,14 @@ import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.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 store = useStore();
const { t } = useI18n();
const { isFeatureFlagEnabled } = usePolicy();
const inboxName = ref('');
const phoneNumber = ref('');
@ -83,10 +87,35 @@ const createChannel = async () => {
const setShowAdvancedOptions = () => {
showAdvancedOptions.value = true;
};
const switchToZapi = () => {
router.push({
name: router.currentRoute.value.name,
params: router.currentRoute.value.params,
query: { provider: 'zapi' },
});
};
</script>
<template>
<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%]">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}

View File

@ -7,10 +7,13 @@ import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
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 {
components: {
NextButton,
PromoBanner,
},
setup() {
return { v$: useVuelidate() };
@ -25,7 +28,17 @@ export default {
};
},
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: {
inboxName: { required },
@ -72,12 +85,33 @@ export default {
);
}
},
switchToZapi() {
router.push({
name: this.$route.name,
params: this.$route.params,
query: { provider: 'zapi' },
});
},
},
};
</script>
<template>
<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">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}

View File

@ -6,12 +6,15 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
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 { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
NextButton,
PromoBanner,
},
props: {
type: {
@ -38,10 +41,21 @@ export default {
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
accountId: 'getCurrentAccountId',
}),
authTokeni18nKey() {
return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN';
},
isWhatsApp() {
return this.type === 'whatsapp';
},
isZapiEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_ZAPI
);
},
},
validations() {
let validations = {
@ -112,12 +126,33 @@ export default {
useAlert(errorMessage);
}
},
switchToZapi() {
router.push({
name: this.$route.name,
params: this.$route.params,
query: { provider: 'zapi' },
});
},
},
};
</script>
<template>
<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">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }}

View File

@ -9,6 +9,7 @@ import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -117,6 +118,32 @@ const handleManualLinkClick = () => {
@click="selectProvider(provider.key)"
/>
</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 v-else-if="showConfiguration">

View File

@ -9,6 +9,7 @@ import { required } from '@vuelidate/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const router = useRouter();
const store = useStore();
@ -22,6 +23,10 @@ const clientToken = ref('');
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(() => ({
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
@ -74,6 +79,21 @@ const createChannel = async () => {
<template>
<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%]">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}

View File

@ -20,6 +20,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
'contact_updated',
'conversation_typing_on',
'conversation_typing_off',
'provider_event_received',
];
const localhostUrl = value => {

View File

@ -91,6 +91,19 @@ class WebhookListener < BaseListener
handle_typing_status(__method__.to_s, event)
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
def handle_typing_status(event_name, event)

View File

@ -28,7 +28,7 @@ class Webhook < ApplicationRecord
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
conversation_typing_on conversation_typing_off].freeze
conversation_typing_on conversation_typing_off provider_event_received].freeze
private

View File

@ -7,6 +7,10 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
@raw_message[:key][:id]
end
def sender_lid
@raw_message[:key][:senderLid]
end
def incoming?
!@raw_message[:key][:fromMe]
end

View File

@ -45,19 +45,28 @@ module Whatsapp::BaileysHandlers::MessagesUpsert
push_name = contact_name
source_id = phone_number_from_jid
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,
inbox: inbox,
contact_attributes: { name: push_name, phone_number: "+#{source_id}" }
contact_attributes: { name: push_name, phone_number: "+#{source_id}", identifier: sender_lid }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
@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
end
def update_contact_identifier
return if @contact.identifier.present?
@contact.update!(identifier: sender_lid)
end
def handle_create_message
create_message(attach_media: %w[image file video audio sticker].include?(message_type))
end

View File

@ -1,14 +1,18 @@
class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseService
include Events::Types
include Whatsapp::BaileysHandlers::ConnectionUpdate
include Whatsapp::BaileysHandlers::MessagesUpsert
include Whatsapp::BaileysHandlers::MessagesUpdate
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']
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(/[\.-]/, '_')
method_name = "process_#{event_prefix}"
if respond_to?(method_name, true)

View File

@ -1,4 +1,5 @@
class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseService
include Events::Types
include Whatsapp::ZapiHandlers::ConnectedCallback
include Whatsapp::ZapiHandlers::DisconnectedCallback
include Whatsapp::ZapiHandlers::ReceivedCallback
@ -8,6 +9,9 @@ class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseServic
def perform
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
method_name = "process_#{event_prefix}"
if respond_to?(method_name, true)

View File

@ -33,17 +33,19 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
!@raw_message[:isGroup] &&
!@raw_message[:isNewsletter] &&
!@raw_message[:broadcast] &&
!@raw_message[:isStatusReply]
!@raw_message[:isStatusReply] &&
!@raw_message.key?(:notification)
end
def message_type # rubocop:disable Metrics/CyclomaticComplexity
return 'reaction' if @raw_message.key?(:reaction)
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
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 '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)
return 'contact' if @raw_message.key?(:contact)
'unsupported'
end
@ -60,6 +62,8 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
@raw_message.dig(:document, :fileName)
when 'reaction'
@raw_message.dig(:reaction, :value)
when 'contact'
@raw_message.dig(:contact, :displayName)
end
end
@ -102,10 +106,35 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
end
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
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(
content: message_content,
account_id: @inbox.account_id,
@ -116,11 +145,9 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
message_type: incoming_message? ? :incoming : :outgoing,
content_attributes: message_content_attributes
)
end
handle_attach_media if attach_media
@message.save!
def notify_channel_of_received_message
inbox.channel.received_messages([@message], @conversation) if incoming_message?
end
@ -140,6 +167,20 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
content_attributes
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
attachment_file = download_attachment_file

View File

@ -13,6 +13,7 @@ module Events::Types
# channel events
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
PROVIDER_EVENT_RECEIVED = 'provider.event_received'
# conversation events
CONVERSATION_CREATED = 'conversation.created'

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -378,4 +378,70 @@ describe WebhookListener do
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

View File

@ -69,6 +69,25 @@ describe Whatsapp::IncomingMessageBaileysService do
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
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(: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',
messageTimestamp: timestamp,
message: { conversation: 'Hello from Baileys' }
@ -766,6 +785,34 @@ describe Whatsapp::IncomingMessageBaileysService do
expect(inbox.conversations).to be_empty
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
context 'when processing messages.update event' do

View File

@ -35,5 +35,20 @@ describe Whatsapp::IncomingMessageZapiService do
expect(Rails.logger).to have_received(:warn).with(/Z-API unsupported event/)
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

View File

@ -455,6 +455,14 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
end.not_to change(Message, :count)
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
service.perform
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')
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
private