From acd1e56a280f0f086cd151edad2d0e5ee703e249 Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Fri, 28 Mar 2025 09:43:43 -0300 Subject: [PATCH] feat: baileys provider for whatsapp (#7) * feat: baileys provider and placeholder for link device modal * chore: drop qrcode.vue in favor of just img tag * chore: update modal props * feat: setup channel provider connection * chore: update .env.example with Baileys API default configuration * feat: add support for Baileys provider in WhatsApp events processing * chore: rename Baileys API default host variable to DEFAULT_BAILEYS_URL * feat: add setup and disconnect methods for Baileys channel provider in inboxes controller that will be implemented * feat: add CHANNEL_CONNECTION_UPDATE event and include it in broadcast data preparation * refactor: simplify channel retrieval logic in WhatsappEventsJob * refactor: revert CHANNEL_UPDATE_EVENTS constant from ActionCableBroadcastJob * feat: add 'baileys' as a provider option in Whatsapp channel model * feat: add provider_connection field to Whatsapp channel model and migration * refactor: remove unnecessary CHANNEL_CONNECTION_UPDATE event type * feat: implement channel provider connection with baileys API * feat: add inbox association to Whatsapp channel model and update webhook URL handling * feat: enhance Baileys service to handle webhook multiple event types * refactor: simplify webhook verification logic in Baileys service * feat: add setup channel provider call, and refactor some logic * chore: adapt logic to new API * refactor: fix typo * refactor: fix import * refactor: fix typo * chore: add fixme comment about race condition * fix: remove double disconnect call * feat: implement message processing for incoming WhatsApp messages * refactor: streamline message type determination and improve readability * chore: increase cache key granularity provider connection info might be updated multiple times within 1 second, so updates might be lost due to cache key not being updated. changing cache key to milliseconds solves this * feat: add `is-loading` to buttons * feat: update send_message method to use 'to' parameter and improve error handling * refactor: simplify test setup and update API key in specs * chore: add setup and disconnect channel provider specs * test: fix spec after increase cache key granularity * feat: handle reconnecting state on modal * style: centered error text * feat: advanced options on create inbox * feat: handle new reconnecting on backend * refactor: update inbox controller specs and leave a FIXME note * test: add specs for Whatsapp::IncomingMessageBaileysService * feat: add baileys configuration page * feat: link device button when disconnected on conversation * chore: refactor .env.example * feat: add TODO for unimplemented methods in IncomingMessageBaileysService * fix: correct method name and update environment variable references in WhatsappBaileysService * refactor: simplify channel lookup by removing redundant method and handling phone number check directly * chore: add TODO for unimplemented event processing methods in IncomingMessageBaileysService * fix: update environment variable references in WhatsappBaileysService tests * chore(webhook): add pt-BR translations * chore: add pt-br translations * chore: inboxname component margin * refactor: inboxname computed prop * feat: enhance WhatsApp provider connection handling and message processing * test: inbox controller * chore: improve baileys connection and messages handling * test: incoming message service baileys * refactor: update provider config validation and improve test setup for WhatsApp Baileys service * fix: ensure only text messages are sent and update message source ID * fix: create message * fix: only update message on success * test: fix broken specs * chore: raise error on unsupported message content type * feat: hide provider connection data from non-admins * fix: update advanced options * chore: move class definition * fix: issue with send_message not returning id --------- Co-authored-by: gabrieljablonski --- .env.example | 6 +- .../api/v1/accounts/inboxes_controller.rb | 22 ++ app/helpers/cache_keys_helper.rb | 2 +- app/javascript/dashboard/api/inboxes.js | 8 + .../components/widgets/InboxName.vue | 23 ++ .../widgets/conversation/MessagesView.vue | 48 ++++ .../widgets/conversation/ReplyBox.vue | 2 +- .../dashboard/composables/useInbox.js | 8 + .../i18n/locale/en/conversation.json | 7 + .../dashboard/i18n/locale/en/inboxMgmt.json | 29 +- .../i18n/locale/pt_BR/conversation.json | 7 + .../i18n/locale/pt_BR/inboxMgmt.json | 29 +- .../i18n/locale/pt_BR/integrations.json | 11 + .../dashboard/settings/inbox/FinishSetup.vue | 39 +++ .../dashboard/settings/inbox/Settings.vue | 3 + .../inbox/channels/BaileysWhatsapp.vue | 171 +++++++++++ .../settings/inbox/channels/Whatsapp.vue | 8 +- .../WhatsappBaileysLinkDeviceModal.vue | 165 +++++++++++ .../inbox/settingsPage/ConfigurationPage.vue | 157 +++++++++- .../dashboard/store/modules/inboxes.js | 14 + .../FluentIcon/dashboard-icons.json | 4 +- app/javascript/shared/mixins/inboxMixin.js | 6 + .../shared/mixins/specs/inboxMixin.spec.js | 8 + app/jobs/webhooks/whatsapp_events_job.rb | 29 +- app/models/channel/whatsapp.rb | 31 +- app/models/concerns/cache_keys.rb | 3 +- app/policies/inbox_policy.rb | 8 + .../incoming_message_baileys_service.rb | 140 +++++++++ .../providers/whatsapp_baileys_service.rb | 101 +++++++ app/views/api/v1/models/_inbox.json.jbuilder | 1 + config/locales/en.yml | 3 + config/locales/pt_BR.yml | 3 + config/routes.rb | 2 + ...939_add_provider_connection_to_whatsapp.rb | 5 + db/schema.rb | 1 + .../v1/accounts/inboxes_controller_spec.rb | 78 +++++ .../super_admin/accounts_controller_spec.rb | 4 +- spec/factories/channel/channel_whatsapp.rb | 5 +- spec/helpers/cache_keys_helper_spec.rb | 2 +- .../incoming_message_baileys_service_spec.rb | 268 ++++++++++++++++++ .../whatsapp_baileys_service_spec.rb | 232 +++++++++++++++ 41 files changed, 1656 insertions(+), 37 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue create mode 100644 app/services/whatsapp/incoming_message_baileys_service.rb create mode 100644 app/services/whatsapp/providers/whatsapp_baileys_service.rb create mode 100644 db/migrate/20250314185939_add_provider_connection_to_whatsapp.rb create mode 100644 spec/services/whatsapp/incoming_message_baileys_service_spec.rb create mode 100644 spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb diff --git a/.env.example b/.env.example index b7ba0920d..a8fdab64a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables # Used to verify the integrity of signed cookies. so ensure a secure value is set -# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. +# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. # Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex @@ -258,3 +258,7 @@ AZURE_APP_SECRET= # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false +# Baileys API Whatsapp provider +BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot +BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 +BAILEYS_PROVIDER_DEFAULT_API_KEY= diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 011faaf28..2d390bba9 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -62,6 +62,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController head :ok end + def setup_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:setup_channel_provider) + render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return + end + + channel.setup_channel_provider + head :ok + end + + def disconnect_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:disconnect_channel_provider) + render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return + end + + channel.disconnect_channel_provider + head :ok + end + def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } diff --git a/app/helpers/cache_keys_helper.rb b/app/helpers/cache_keys_helper.rb index aab33e44c..366c87383 100644 --- a/app/helpers/cache_keys_helper.rb +++ b/app/helpers/cache_keys_helper.rb @@ -10,6 +10,6 @@ module CacheKeysHelper return value_from_cache if value_from_cache.present? # zero epoch time: 1970-01-01 00:00:00 UTC - '0000000000' + '0000000000000' end end diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 8c09791c8..7af82a720 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -28,6 +28,14 @@ class Inboxes extends CacheEnabledApiClient { agent_bot: botId, }); } + + setupChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/setup_channel_provider`); + } + + disconnectChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/components/widgets/InboxName.vue b/app/javascript/dashboard/components/widgets/InboxName.vue index 693162f95..a5840caaa 100644 --- a/app/javascript/dashboard/components/widgets/InboxName.vue +++ b/app/javascript/dashboard/components/widgets/InboxName.vue @@ -7,6 +7,14 @@ export default { type: Object, default: () => {}, }, + withPhoneNumber: { + type: Boolean, + default: false, + }, + withProviderConnectionStatus: { + type: Boolean, + default: false, + }, }, computed: { computedInboxClass() { @@ -14,6 +22,9 @@ export default { const classByType = getInboxClassByType(type, phoneNumber); return classByType; }, + providerConnection() { + return this.inbox.provider_connection?.connection; + }, }, }; @@ -28,5 +39,17 @@ export default { size="12" /> {{ inbox.name }} + {{ + inbox.phone_number + }} + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index ae798d8d9..e1ee9ce2d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -5,6 +5,7 @@ import { useConfig } from 'dashboard/composables/useConfig'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useAI } from 'dashboard/composables/useAI'; import { useMapGetter } from 'dashboard/composables/store'; +import { useAdmin } from 'dashboard/composables/useAdmin'; // components import ReplyBox from './ReplyBox.vue'; @@ -36,6 +37,7 @@ import { REPLY_POLICY } from 'shared/constants/links'; import wootConstants from 'dashboard/constants/globals'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { FEATURE_FLAGS } from '../../../featureFlags'; +import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -47,6 +49,7 @@ export default { Banner, ConversationLabelSuggestion, NextButton, + WhatsappBaileysLinkDeviceModal, }, mixins: [inboxMixin], props: { @@ -61,6 +64,7 @@ export default { }, emits: ['contactPanelToggle'], setup() { + const { isAdmin } = useAdmin(); const isPopOutReplyBox = ref(false); const { isEnterprise } = useConfig(); @@ -107,6 +111,7 @@ export default { fetchIntegrationsIfRequired, fetchLabelSuggestions, showNextBubbles, + isAdmin, }; }, data() { @@ -118,6 +123,7 @@ export default { isProgrammaticScroll: false, messageSentSinceOpened: false, labelSuggestions: [], + showBaileysLinkDeviceModal: false, }; }, @@ -128,6 +134,9 @@ export default { listLoadingStatus: 'getAllMessagesLoaded', currentAccountId: 'getCurrentAccountId', }), + currentInbox() { + return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id); + }, isOpen() { return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; }, @@ -266,6 +275,9 @@ export default { return { incoming, outgoing }; }, + inboxProviderConnection() { + return this.currentInbox.provider_connection?.connection; + }, }, watch: { @@ -476,12 +488,48 @@ export default { return false; }); }, + onOpenBaileysLinkDeviceModal() { + this.showBaileysLinkDeviceModal = true; + }, + onCloseBaileysLinkDeviceModal() { + this.showBaileysLinkDeviceModal = false; + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 845206d1c..974dd4b6d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -88,6 +88,9 @@ export default { if (this.isATwilioWhatsAppChannel) { return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'); } + if (this.isAWhatsAppBaileysChannel) { + return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'); + } return ''; }, tabs() { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue new file mode 100644 index 000000000..dbfa2d739 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue @@ -0,0 +1,171 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index 68a9d8bcd..f07d266af 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -3,6 +3,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue'; import Twilio from './Twilio.vue'; import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue'; +import BaileysWhatsapp from './BaileysWhatsapp.vue'; export default { components: { @@ -10,6 +11,7 @@ export default { Twilio, ThreeSixtyDialogWhatsapp, CloudWhatsapp, + BaileysWhatsapp, }, data() { return { @@ -37,12 +39,16 @@ export default { + - + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue new file mode 100644 index 000000000..bf12a8982 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index bc5355c3c..792a15299 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -5,8 +5,11 @@ import SettingsSection from '../../../../../components/SettingsSection.vue'; import ImapSettings from '../ImapSettings.vue'; import SmtpSettings from '../SmtpSettings.vue'; import { useVuelidate } from '@vuelidate/core'; -import { required } from '@vuelidate/validators'; import NextButton from 'dashboard/components-next/button/Button.vue'; +import { requiredIf } from '@vuelidate/validators'; +import { isValidURL } from '../../../../../helper/URLHelper'; +import WhatsappBaileysLinkDeviceModal from '../components/WhatsappBaileysLinkDeviceModal.vue'; +import InboxName from '../../../../../components/widgets/InboxName.vue'; export default { components: { @@ -14,6 +17,8 @@ export default { ImapSettings, SmtpSettings, NextButton, + WhatsappBaileysLinkDeviceModal, + InboxName, }, mixins: [inboxMixin], props: { @@ -29,10 +34,17 @@ export default { return { hmacMandatory: false, whatsAppInboxAPIKey: '', + whatsAppProviderUrl: '', + showBaileysLinkDeviceModal: false, }; }, - validations: { - whatsAppInboxAPIKey: { required }, + validations() { + return { + whatsAppInboxAPIKey: { + requiredIf: requiredIf(!this.isAWhatsAppBaileysChannel), + }, + whatsAppProviderUrl: { isValidURL: value => !value || isValidURL(value) }, + }; }, watch: { inbox() { @@ -83,6 +95,31 @@ export default { useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); } }, + async updateWhatsAppProviderUrl() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + provider_url: this.whatsAppProviderUrl, + }, + }, + }; + + 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')); + } + }, + onOpenBaileysLinkDeviceModal() { + this.showBaileysLinkDeviceModal = true; + }, + onCloseBaileysLinkDeviceModal() { + this.showBaileysLinkDeviceModal = false; + }, }, }; @@ -194,8 +231,8 @@ export default { -
-
+
+
+
+ +
+ +
+ + + {{ + $t( + 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON' + ) + }} + +
+
+ +
+ + + {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }} + +
+ + {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_ERROR') }} + +
+ + +
+ + + {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }} + +
+
+
+