From 11e9932e9b232dc2b0faf368abdf0503e1792527 Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Mon, 13 Apr 2026 11:38:11 -0300 Subject: [PATCH] feat(whatsapp): show contact typing and recording indicators via baileys presence (#264) * feat(whatsapp): show contact typing and recording indicators via baileys presence Subscribe to WhatsApp presence updates via the baileys-api provider to display real-time typing and recording indicators in the dashboard. - Handle presence.update webhook events (composing, recording, paused, available) and broadcast via ActionCable - Add conversation.recording event to ActionCable, webhook, and channel listeners for parity with typing_on/typing_off - Show "typing..." / "recording..." in green text on the chat list, replacing the message preview - Show "X is typing" / "X is recording audio" in the conversation view - Add presence_subscribe provider config option (default off) to gate all subscription calls to the baileys-api - Subscribe to presence on conversation open and periodically (1 min) for the top 10 chat list conversations - Consolidate contact LID from presence.update jidAlt payload - Prevent echo-back of contact typing events to the channel Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback - Filter chat list typing indicator to contact-only events - Add dedupe to presence subscribe bulk calls - Use strong parameters for conversation_ids - Remove redundant YAML quotes in swagger webhook enum Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback - Extract phone from data[:id] when JID is @s.whatsapp.net (fallback when jidAlt is absent) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback - Filter recording users in getTypingUsersText to show correct names - Add 10s timeout to presence_subscribe HTTP request Co-Authored-By: Claude Opus 4.6 (1M context) * fix: scope typing timer per user instead of per conversation Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make presence subscribe best-effort with rescue per channel Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback - Add messagePreviewClass to typing preview for consistent padding - Fix specs to use WebMock assertions instead of instance spying Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../v1/accounts/conversations_controller.rb | 16 +- .../dashboard/api/inbox/conversation.js | 10 ++ .../dashboard/components/ChatList.vue | 20 +++ .../widgets/conversation/ConversationCard.vue | 24 +++ .../dashboard/helper/actionCable.js | 39 +++-- app/javascript/dashboard/helper/commons.js | 13 +- .../dashboard/i18n/locale/en/chatlist.json | 2 + .../i18n/locale/en/conversation.json | 5 + .../dashboard/i18n/locale/en/inboxMgmt.json | 6 + .../i18n/locale/en/integrations.json | 1 + .../dashboard/i18n/locale/pt_BR/chatlist.json | 2 + .../i18n/locale/pt_BR/conversation.json | 5 + .../i18n/locale/pt_BR/inboxMgmt.json | 6 + .../i18n/locale/pt_BR/integrations.json | 1 + .../inbox/channels/BaileysWhatsapp.vue | 13 ++ .../inbox/settingsPage/ConfigurationPage.vue | 42 ++++++ .../integrations/Webhooks/WebhookForm.vue | 1 + .../store/modules/conversationTypingStatus.js | 13 +- .../store/modules/conversations/actions.js | 1 + app/listeners/action_cable_listener.rb | 16 ++ app/listeners/channel_listener.rb | 3 +- app/listeners/webhook_listener.rb | 4 + app/models/channel/whatsapp.rb | 1 + app/models/webhook.rb | 4 +- .../presence_subscribe_service.rb | 46 ++++++ .../baileys_handlers/presence_update.rb | 72 +++++++++ .../incoming_message_baileys_service.rb | 1 + .../providers/whatsapp_baileys_service.rb | 17 ++- config/routes.rb | 2 + .../presence_subscribe_service_spec.rb | 96 ++++++++++++ .../baileys_handlers/presence_update_spec.rb | 138 ++++++++++++++++++ swagger/definitions/resource/webhook.yml | 28 +++- 32 files changed, 613 insertions(+), 35 deletions(-) create mode 100644 app/services/conversations/presence_subscribe_service.rb create mode 100644 app/services/whatsapp/baileys_handlers/presence_update.rb create mode 100644 spec/services/conversations/presence_subscribe_service_spec.rb create mode 100644 spec/services/whatsapp/baileys_handlers/presence_update_spec.rb diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8b23975b2..38bb5d1d0 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro include DateRangeHelper include HmacConcern - before_action :conversation, except: [:index, :meta, :search, :create, :filter] + before_action :conversation, except: [:index, :meta, :search, :create, :filter, :presence_subscribe_bulk] before_action :inbox, :contact, :contact_inbox, only: [:create] ATTACHMENT_RESULTS_PER_PAGE = 100 @@ -34,6 +34,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro .per(ATTACHMENT_RESULTS_PER_PAGE) end + def presence_subscribe_bulk + Conversations::PresenceSubscribeService.new(Current.account, presence_subscribe_params[:conversation_ids]).perform + head :ok + end + def show; end def create @@ -112,6 +117,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro head :ok end + def presence_subscribe + Conversations::PresenceSubscribeService.new(Current.account, [@conversation.display_id]).perform + head :ok + end + def update_last_seen # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. @@ -157,6 +167,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro params.permit(:page) end + def presence_subscribe_params + params.permit(conversation_ids: []) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) updates = { agent_last_seen_at: last_seen_at } updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present? diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index ea2802cae..acbebf9eb 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -90,6 +90,16 @@ class ConversationApi extends ApiClient { }); } + presenceSubscribe(conversationId) { + return axios.post(`${this.url}/${conversationId}/presence_subscribe`); + } + + presenceSubscribeBulk(conversationIds) { + return axios.post(`${this.url}/presence_subscribe_bulk`, { + conversation_ids: conversationIds, + }); + } + mute(conversationId) { return axios.post(`${this.url}/${conversationId}/mute`); } diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index e9c135e51..f3e21954c 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -8,6 +8,7 @@ import { computed, watch, onMounted, + onBeforeUnmount, defineEmits, } from 'vue'; import { useStore } from 'vuex'; @@ -47,6 +48,7 @@ import { useConversationRequiredAttributes } from 'dashboard/composables/useConv import { emitter } from 'shared/helpers/mitt'; +import ConversationAPI from 'dashboard/api/inbox/conversation'; import wootConstants from 'dashboard/constants/globals'; import advancedFilterOptions from './widgets/conversation/advancedFilterItems'; import filterQueryGenerator from '../helper/filterQueryGenerator.js'; @@ -804,6 +806,17 @@ useEmitter('fetch_conversation_stats', () => { store.dispatch('conversationStats/get', conversationFilters.value); }); +let lastSubscribedIds = ''; +const subscribePresenceForTopChats = () => { + const ids = conversationList.value.slice(0, 10).map(c => c.id); + const key = ids.join(','); + if (!ids.length || key === lastSubscribedIds) return; + lastSubscribedIds = key; + ConversationAPI.presenceSubscribeBulk(ids).catch(() => {}); +}; + +let presenceInterval = null; + onMounted(() => { store.dispatch('setChatListFilters', conversationFilters.value); setFiltersFromUISettings(); @@ -814,6 +827,13 @@ onMounted(() => { if (hasActiveFolders.value) { store.dispatch('campaigns/get'); } + presenceInterval = setInterval(subscribePresenceForTopChats, 60000); +}); + +watch(conversationList, subscribePresenceForTopChats); + +onBeforeUnmount(() => { + if (presenceInterval) clearInterval(presenceInterval); }); const deleteConversationDialogRef = ref(null); diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index b114cf72d..fca692d0d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; import { useStore, useMapGetter } from 'dashboard/composables/store'; +import { useI18n } from 'vue-i18n'; import { getLastMessage } from 'dashboard/helper/conversationHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import Avatar from 'next/avatar/Avatar.vue'; @@ -112,6 +113,21 @@ const isInboxNameVisible = computed(() => !activeInbox.value); const lastMessageInChat = computed(() => getLastMessage(props.chat)); +const { t } = useI18n(); +const typingUsersList = computed(() => { + const users = store.getters['conversationTypingStatus/getUserList']( + props.chat.id + ); + return users.filter(u => u.type === 'contact'); +}); +const isAnyoneTyping = computed(() => typingUsersList.value.length > 0); +const typingPreviewText = computed(() => { + if (!isAnyoneTyping.value) return ''; + return typingUsersList.value.some(u => u.recording) + ? t('CHAT_LIST.RECORDING') + : t('CHAT_LIST.TYPING'); +}); + const voiceCallData = computed(() => ({ status: props.chat.additional_attributes?.call_status, direction: props.chat.additional_attributes?.call_direction, @@ -350,6 +366,14 @@ const deleteConversation = () => { :direction="voiceCallData.direction" :message-preview-class="messagePreviewClass" /> +

+ {{ typingPreviewText }} +

{ - const conversationId = conversation.id; + const timerKey = `${conversation.id}:${user.type}:${user.id}`; - this.clearTimer(conversationId); + this.clearTimer(timerKey); this.app.$store.dispatch('conversationTypingStatus/create', { - conversationId, - user, + conversationId: conversation.id, + user: { ...user, recording: false }, }); - this.initTimer({ conversation, user }); + this.initTimer({ conversation, user, timerKey }); + }; + + onRecording = ({ conversation, user }) => { + const timerKey = `${conversation.id}:${user.type}:${user.id}`; + + this.clearTimer(timerKey); + this.app.$store.dispatch('conversationTypingStatus/create', { + conversationId: conversation.id, + user: { ...user, recording: true }, + }); + this.initTimer({ conversation, user, timerKey }); }; onTypingOff = ({ conversation, user }) => { - const conversationId = conversation.id; + const timerKey = `${conversation.id}:${user.type}:${user.id}`; - this.clearTimer(conversationId); + this.clearTimer(timerKey); this.app.$store.dispatch('conversationTypingStatus/destroy', { - conversationId, + conversationId: conversation.id, user, }); }; @@ -195,19 +207,18 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('addMentions', data); }; - clearTimer = conversationId => { - const timerEvent = this.CancelTyping[conversationId]; + clearTimer = timerKey => { + const timerEvent = this.CancelTyping[timerKey]; if (timerEvent) { clearTimeout(timerEvent); - this.CancelTyping[conversationId] = null; + this.CancelTyping[timerKey] = null; } }; - initTimer = ({ conversation, user }) => { - const conversationId = conversation.id; + initTimer = ({ conversation, user, timerKey }) => { // Turn off typing automatically after 30 seconds - this.CancelTyping[conversationId] = setTimeout(() => { + this.CancelTyping[timerKey] = setTimeout(() => { this.onTypingOff({ conversation, user }); }, 30000); }; diff --git a/app/javascript/dashboard/helper/commons.js b/app/javascript/dashboard/helper/commons.js index 3be7538bb..13f82ae3d 100644 --- a/app/javascript/dashboard/helper/commons.js +++ b/app/javascript/dashboard/helper/commons.js @@ -26,21 +26,24 @@ export const isJSONValid = value => { }; export const getTypingUsersText = (users = []) => { - const count = users.length; - const [firstUser, secondUser] = users; + const anyRecording = users.some(u => u.recording); + const prefix = anyRecording ? 'RECORDING' : 'TYPING'; + const activeUsers = anyRecording ? users.filter(u => u.recording) : users; + const count = activeUsers.length; + const [firstUser, secondUser] = activeUsers; if (count === 1) { - return ['TYPING.ONE', { user: firstUser.name }]; + return [`${prefix}.ONE`, { user: firstUser.name }]; } if (count === 2) { return [ - 'TYPING.TWO', + `${prefix}.TWO`, { user: firstUser.name, secondUser: secondUser.name }, ]; } - return ['TYPING.MULTIPLE', { user: firstUser.name, count: count - 1 }]; + return [`${prefix}.MULTIPLE`, { user: firstUser.name, count: count - 1 }]; }; export const createPendingMessage = data => { diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 0e8e87a04..810bbb5a6 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -136,6 +136,8 @@ "READ": "Read successfully", "DELIVERED": "Delivered successfully", "NO_MESSAGES": "No Messages", + "TYPING": "typing...", + "RECORDING": "recording...", "NO_CONTENT": "No content available", "HIDE_QUOTED_TEXT": "Hide Quoted Text", "SHOW_QUOTED_TEXT": "Show Quoted Text", diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 58876b83a..6362a876c 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -640,6 +640,11 @@ "TWO": "{user} and {secondUser} are typing", "MULTIPLE": "{user} and {count} others are typing" }, + "RECORDING": { + "ONE": "{user} is recording audio", + "TWO": "{user} and {secondUser} are recording audio", + "MULTIPLE": "{user} and {count} others are recording audio" + }, "COPILOT": { "TRY_THESE_PROMPTS": "Try these prompts" }, diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index e950318d1..180821d25 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -299,6 +299,9 @@ "MARK_AS_READ": { "LABEL": "Send read receipts" }, + "PRESENCE_SUBSCRIBE": { + "LABEL": "Show contact typing indicators" + }, "INSTANCE_ID": { "LABEL": "Instance ID", "PLACEHOLDER": "Please enter your instance ID", @@ -845,6 +848,9 @@ "WHATSAPP_MARK_AS_READ_TITLE": "Read receipts", "WHATSAPP_MARK_AS_READ_SUBHEADER": "If turned off, when a message is viewed in Chatwoot, a read receipt will not be sent to the sender. Your messages will still be able to receive read receipts from the sender.", "WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts", + "WHATSAPP_PRESENCE_SUBSCRIBE_TITLE": "Contact typing indicators", + "WHATSAPP_PRESENCE_SUBSCRIBE_SUBHEADER": "When enabled, you will see typing indicators in the conversation when a WhatsApp contact is typing or recording audio. Requires reconnecting the channel to take effect.", + "WHATSAPP_PRESENCE_SUBSCRIBE_LABEL": "Show contact typing indicators", "WHATSAPP_INSTANCE_ID_TITLE": "Instance ID", "WHATSAPP_INSTANCE_ID_SUBHEADER": "Your Z-API Instance ID.", "WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Update Instance ID", diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 55ec1e914..43db51747 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -60,6 +60,7 @@ "CONTACT_UPDATED": "Contact updated", "CONVERSATION_TYPING_ON": "Conversation Typing On", "CONVERSATION_TYPING_OFF": "Conversation Typing Off", + "CONVERSATION_RECORDING": "Conversation Recording Audio", "PROVIDER_EVENT_RECEIVED": "Provider Event Received", "INTERNAL_CHAT_MESSAGE_CREATED": "Internal chat message created", "INTERNAL_CHAT_MESSAGE_UPDATED": "Internal chat message updated", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/chatlist.json b/app/javascript/dashboard/i18n/locale/pt_BR/chatlist.json index ff4711143..978f776e7 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/chatlist.json @@ -136,6 +136,8 @@ "READ": "Lido com sucesso", "DELIVERED": "Entregue com sucesso", "NO_MESSAGES": "Nova Mensagem", + "TYPING": "digitando...", + "RECORDING": "gravando...", "NO_CONTENT": "Nenhum conteúdo disponível", "HIDE_QUOTED_TEXT": "Ocultar Texto Citado", "SHOW_QUOTED_TEXT": "Mostrar Texto Citado", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json index 0dec5654e..9172bf3e8 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json @@ -640,6 +640,11 @@ "TWO": "{user} e {secondUser} estão digitando", "MULTIPLE": "{user} e {count} outros estão digitando" }, + "RECORDING": { + "ONE": "{user} está gravando áudio", + "TWO": "{user} e {secondUser} estão gravando áudio", + "MULTIPLE": "{user} e {count} outros estão gravando áudio" + }, "COPILOT": { "TRY_THESE_PROMPTS": "Experimente estes comandos" }, diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json index 792dc7cda..95348b58d 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json @@ -299,6 +299,9 @@ "MARK_AS_READ": { "LABEL": "Enviar confirmações de leitura" }, + "PRESENCE_SUBSCRIBE": { + "LABEL": "Mostrar indicadores de digitação do contato" + }, "INSTANCE_ID": { "LABEL": "ID da instância", "PLACEHOLDER": "Por favor, insira o ID da sua instância", @@ -833,6 +836,9 @@ "WHATSAPP_MARK_AS_READ_TITLE": "Confirmações de leitura", "WHATSAPP_MARK_AS_READ_SUBHEADER": "Se essa opção estiver desativada, ao visualizar uma mensagem pelo Chatwoot, não será enviada uma confirmação de leitura para o remetente. As suas mensagens ainda poderão receber confirmações de leitura.", "WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura", + "WHATSAPP_PRESENCE_SUBSCRIBE_TITLE": "Indicadores de digitação do contato", + "WHATSAPP_PRESENCE_SUBSCRIBE_SUBHEADER": "Quando ativado, você verá indicadores de digitação na conversa quando um contato do WhatsApp estiver digitando ou gravando áudio. Requer reconectar o canal para entrar em vigor.", + "WHATSAPP_PRESENCE_SUBSCRIBE_LABEL": "Mostrar indicadores de digitação do contato", "WHATSAPP_INSTANCE_ID_TITLE": "ID da Instância", "WHATSAPP_INSTANCE_ID_SUBHEADER": "Seu ID da Instância Z-API.", "WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Atualizar ID da Instância", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json b/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json index 7d893fc03..a8af9d5ed 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json @@ -60,6 +60,7 @@ "CONTACT_UPDATED": "Contato atualizado", "CONVERSATION_TYPING_ON": "Status de Digitação ativado", "CONVERSATION_TYPING_OFF": "Status de Digitação desativado", + "CONVERSATION_RECORDING": "Gravação de Áudio na Conversa", "PROVIDER_EVENT_RECEIVED": "Evento do Provedor Recebido", "INTERNAL_CHAT_MESSAGE_CREATED": "Mensagem do chat interno criada", "INTERNAL_CHAT_MESSAGE_UPDATED": "Mensagem do chat interno atualizada", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue index 55593f94e..26e3512ff 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue @@ -22,6 +22,7 @@ const apiKey = ref(''); const providerUrl = ref(''); const showAdvancedOptions = ref(false); const markAsRead = ref(true); +const presenceSubscribe = ref(false); const uiFlags = computed(() => store.getters['inboxes/getUIFlags']); @@ -51,6 +52,7 @@ const createChannel = async () => { try { const providerConfig = { mark_as_read: markAsRead.value, + presence_subscribe: presenceSubscribe.value, }; if (apiKey.value || providerUrl.value) { @@ -169,6 +171,17 @@ const setShowAdvancedOptions = () => { + +
+ +
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 8db8dcf7b..67741b74e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -56,6 +56,7 @@ export default { baileysProviderUrl: '', showLinkDeviceModal: false, markAsRead: true, + presenceSubscribe: false, zapiInstanceId: '', zapiToken: '', zapiClientToken: '', @@ -116,6 +117,8 @@ export default { }); this.baileysProviderUrl = this.inbox.provider_config?.provider_url ?? ''; this.markAsRead = this.inbox.provider_config?.mark_as_read ?? true; + this.presenceSubscribe = + this.inbox.provider_config?.presence_subscribe ?? false; this.zapiInstanceId = this.inbox.provider_config?.instance_id ?? ''; this.zapiToken = this.inbox.provider_config?.token ?? ''; this.zapiClientToken = this.inbox.provider_config?.client_token ?? ''; @@ -254,6 +257,24 @@ export default { useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); } }, + async updatePresenceSubscribe() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + presence_subscribe: this.presenceSubscribe, + }, + }, + }; + 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')); + } + }, onOpenLinkDeviceModal() { this.showLinkDeviceModal = true; }, @@ -716,6 +737,27 @@ export default {
+ +
+ + +
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 773fee0fb..0b835b95b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -24,6 +24,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [ 'contact_updated', 'conversation_typing_on', 'conversation_typing_off', + 'conversation_recording', 'provider_event_received', 'internal_chat_message_created', 'internal_chat_message_updated', diff --git a/app/javascript/dashboard/store/modules/conversationTypingStatus.js b/app/javascript/dashboard/store/modules/conversationTypingStatus.js index 0e8c1c76c..e3788701c 100644 --- a/app/javascript/dashboard/store/modules/conversationTypingStatus.js +++ b/app/javascript/dashboard/store/modules/conversationTypingStatus.js @@ -38,10 +38,17 @@ export const mutations = { { conversationId, user } ) => { const records = $state.records[conversationId] || []; - const hasUserRecordAlready = !!records.filter( + const existingIndex = records.findIndex( record => record.id === user.id && record.type === user.type - ).length; - if (!hasUserRecordAlready) { + ); + if (existingIndex >= 0) { + const updatedRecords = [...records]; + updatedRecords[existingIndex] = user; + $state.records = { + ...$state.records, + [conversationId]: updatedRecords, + }; + } else { $state.records = { ...$state.records, [conversationId]: [...records, user], diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index cf03291e1..3ed2ffe7d 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -204,6 +204,7 @@ const actions = { // Ignore error } } + ConversationApi.presenceSubscribe(data.id).catch(() => {}); }, assignAgent: async ({ dispatch }, { conversationId, agentId }) => { diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index 69864f47a..12f72de61 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -154,6 +154,22 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength ) end + def conversation_recording(event) + conversation = event.data[:conversation] + account = conversation.account + user = event.data[:user] + tokens = typing_event_listener_tokens(account, conversation, user) + + broadcast( + account, + tokens, + CONVERSATION_RECORDING, + conversation: conversation.push_event_data, + user: user.push_event_data, + is_private: event.data[:is_private] || false + ) + end + def conversation_typing_off(event) conversation = event.data[:conversation] account = conversation.account diff --git a/app/listeners/channel_listener.rb b/app/listeners/channel_listener.rb index f70fd0288..ab836cbfd 100644 --- a/app/listeners/channel_listener.rb +++ b/app/listeners/channel_listener.rb @@ -46,8 +46,9 @@ class ChannelListener < BaseListener private def handle_typing_event(event) - is_private, conversation = event.data.values_at(:is_private, :conversation) + is_private, conversation, user = event.data.values_at(:is_private, :conversation, :user) return if is_private + return if user.is_a?(Contact) channel = conversation.inbox.channel return unless channel.respond_to?(:toggle_typing_status) diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index 4b3154634..cb7bd5c42 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -114,6 +114,10 @@ class WebhookListener < BaseListener handle_typing_status(__method__.to_s, event) end + def conversation_recording(event) + handle_typing_status(__method__.to_s, event) + end + %i[internal_chat_message_created internal_chat_message_updated internal_chat_message_deleted].each do |event_name| define_method(event_name) do |event| message = event.data[:message] diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 5f5a2363b..f416f86f2 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -172,6 +172,7 @@ class Channel::Whatsapp < ApplicationRecord end delegate :setup_channel_provider, to: :provider_service + delegate :presence_subscribe, to: :provider_service delegate :send_message, to: :provider_service delegate :send_template, to: :provider_service delegate :sync_templates, to: :provider_service diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 21990673d..05e524667 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -32,8 +32,8 @@ class Webhook < ApplicationRecord ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated message_created message_incoming message_outgoing message_updated webwidget_triggered - inbox_created inbox_updated conversation_typing_on conversation_typing_off provider_event_received - internal_chat_message_created internal_chat_message_updated + inbox_created inbox_updated conversation_typing_on conversation_typing_off conversation_recording + provider_event_received internal_chat_message_created internal_chat_message_updated internal_chat_message_deleted internal_chat_channel_updated].freeze private diff --git a/app/services/conversations/presence_subscribe_service.rb b/app/services/conversations/presence_subscribe_service.rb new file mode 100644 index 000000000..7970149a4 --- /dev/null +++ b/app/services/conversations/presence_subscribe_service.rb @@ -0,0 +1,46 @@ +class Conversations::PresenceSubscribeService + def initialize(account, conversation_ids) + @account = account + @conversation_ids = Array(conversation_ids).first(10) + end + + def perform + return if @conversation_ids.blank? + + jids_by_channel = collect_jids + jids_by_channel.each do |channel, jids| + channel.presence_subscribe(jids) + rescue StandardError => e + Rails.logger.error "PresenceSubscribeService: failed for channel #{channel.id}: #{e.message}" + end + end + + private + + def collect_jids + conversations = @account.conversations + .where(display_id: @conversation_ids) + .includes(inbox: :channel, contact: []) + result = {} + conversations.each do |conv| + channel = conv.inbox.channel + next unless channel.is_a?(Channel::Whatsapp) && channel.provider_config&.dig('presence_subscribe') + + jid = contact_jid(conv.contact) + next if jid.blank? + + (result[channel] ||= []) << jid + end + result + end + + def contact_jid(contact) + contact.identifier.presence || phone_jid(contact.phone_number) + end + + def phone_jid(phone_number) + return if phone_number.blank? + + "#{phone_number.delete('+')}@s.whatsapp.net" + end +end diff --git a/app/services/whatsapp/baileys_handlers/presence_update.rb b/app/services/whatsapp/baileys_handlers/presence_update.rb new file mode 100644 index 000000000..053969293 --- /dev/null +++ b/app/services/whatsapp/baileys_handlers/presence_update.rb @@ -0,0 +1,72 @@ +module Whatsapp::BaileysHandlers::PresenceUpdate + include Events::Types + + private + + def process_presence_update + data = processed_params[:data] + return if data[:id].blank? || data[:id].include?('@g.us') + + lid, phone = extract_presence_identifiers(data) + consolidate_contact(lid, phone) if lid && phone + + data[:presences]&.each_value do |presence_data| + handle_presence(lid, phone, presence_data) + end + end + + def extract_presence_identifiers(data) + jid = data[:id] + lid = extract_jid_user(jid) if jid&.include?('@lid') + phone = extract_jid_user(jid) if jid&.include?('@s.whatsapp.net') + phone ||= extract_jid_user(data[:jidAlt]) if data[:jidAlt].present? + [lid, phone] + end + + def handle_presence(lid, phone, presence_data) + event = presence_event(presence_data[:lastKnownPresence]) + return unless event + + contact_inbox = find_presence_contact_inbox(lid, phone) + return unless contact_inbox + + conversation = inbox.conversations.where(contact_id: contact_inbox.contact_id).where.not(status: :resolved).last + return unless conversation + + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: contact_inbox.contact, is_private: false) + end + + def find_presence_contact_inbox(lid, phone) + contact_inbox = inbox.contact_inboxes.find_by(source_id: lid) if lid + contact_inbox ||= inbox.contact_inboxes.find_by(source_id: phone) if phone + contact_inbox ||= find_contact_inbox_by_phone(phone) if phone + contact_inbox + end + + def find_contact_inbox_by_phone(phone) + contact = inbox.contacts.find_by(phone_number: "+#{phone}") + return unless contact + + inbox.contact_inboxes.find_by(contact_id: contact.id) + end + + def consolidate_contact(lid, phone) + Whatsapp::ContactInboxConsolidationService.new( + inbox: inbox, phone: phone, lid: lid, identifier: "#{lid}@lid" + ).perform + end + + def extract_jid_user(jid) + return unless jid + + jid.split('@').first.split(':').first + end + + def presence_event(status) + case status + when 'composing' then CONVERSATION_TYPING_ON + when 'recording' then CONVERSATION_RECORDING + when 'paused', 'unavailable', 'available' then CONVERSATION_TYPING_OFF + end + end +end diff --git a/app/services/whatsapp/incoming_message_baileys_service.rb b/app/services/whatsapp/incoming_message_baileys_service.rb index 6b6df8eea..47a1540d3 100644 --- a/app/services/whatsapp/incoming_message_baileys_service.rb +++ b/app/services/whatsapp/incoming_message_baileys_service.rb @@ -7,6 +7,7 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer include Whatsapp::BaileysHandlers::GroupParticipantsUpdate include Whatsapp::BaileysHandlers::GroupsUpdate include Whatsapp::BaileysHandlers::GroupsActivity + include Whatsapp::BaileysHandlers::PresenceUpdate class InvalidWebhookVerifyToken < StandardError; end diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 1505113fc..52161798e 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -47,7 +47,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'], # TODO: Remove on Baileys v2, default will be false includeMedia: false, - groupsEnabled: self.class.groups_enabled? + groupsEnabled: self.class.groups_enabled?, + autoPresenceSubscribe: whatsapp_channel.provider_config['presence_subscribe'] || false }.compact.to_json ) @@ -311,6 +312,19 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end + def presence_subscribe(jids) + response = HTTParty.post( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/presence-subscribe", + headers: api_headers, + body: { jids: Array(jids) }.to_json, + timeout: 10 + ) + + raise ProviderUnavailableError unless process_response(response) + + response.parsed_response&.dig('data') + end + def update_presence(status) status_map = { 'online' => 'available', @@ -848,6 +862,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer :disconnect_channel_provider, :send_message, :toggle_typing_status, + :presence_subscribe, :update_presence, :read_messages, :unread_message, diff --git a/config/routes.rb b/config/routes.rb index 215ef2d6a..e000bf091 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -127,6 +127,7 @@ Rails.application.routes.draw do get :meta get :search post :filter + post :presence_subscribe_bulk end scope module: :conversations do resources :messages, only: [:index, :create, :destroy, :update] do @@ -152,6 +153,7 @@ Rails.application.routes.draw do post :toggle_status post :toggle_priority post :toggle_typing_status + post :presence_subscribe post :update_last_seen post :unread post :custom_attributes diff --git a/spec/services/conversations/presence_subscribe_service_spec.rb b/spec/services/conversations/presence_subscribe_service_spec.rb new file mode 100644 index 000000000..7b4733ade --- /dev/null +++ b/spec/services/conversations/presence_subscribe_service_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +describe Conversations::PresenceSubscribeService do + let!(:whatsapp_channel) do + create(:channel_whatsapp, + provider: 'baileys', + provider_config: { webhook_verify_token: 'token', presence_subscribe: presence_enabled }, + validate_provider_config: false, + received_messages: false) + end + let(:inbox) { whatsapp_channel.inbox } + let(:account) { inbox.account } + let(:contact) { create(:contact, account: account, phone_number: '+5521999999999', identifier: '12345@lid') } + let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345') } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) } + + let(:presence_subscribe_url) { "https://baileys.api/connections/#{whatsapp_channel.phone_number}/presence-subscribe" } + let(:setup_url) { "https://baileys.api/connections/#{whatsapp_channel.phone_number}" } + let(:json_headers) { { 'Content-Type' => 'application/json' } } + + before do + stub_request(:post, setup_url).to_return(status: 200, body: {}.to_json) + stub_request(:post, presence_subscribe_url) + .to_return(status: 200, body: { data: { subscribed: [], skipped: [] } }.to_json, headers: json_headers) + end + + describe '#perform' do + context 'when presence_subscribe is enabled' do + let(:presence_enabled) { true } + + it 'calls presence_subscribe with the contact JID' do + described_class.new(account, [conversation.display_id]).perform + + expect(WebMock).to have_requested(:post, presence_subscribe_url) + .with(body: { jids: ['12345@lid'] }.to_json) + end + + it 'falls back to phone JID when identifier is blank' do + contact.update!(identifier: nil) + described_class.new(account, [conversation.display_id]).perform + + expect(WebMock).to have_requested(:post, presence_subscribe_url) + .with(body: { jids: ['5521999999999@s.whatsapp.net'] }.to_json) + end + + it 'skips contacts with no identifier or phone' do + contact.update!(identifier: nil, phone_number: nil) + described_class.new(account, [conversation.display_id]).perform + + expect(WebMock).not_to have_requested(:post, presence_subscribe_url) + end + + it 'limits to 10 conversation IDs' do + ids = (1..15).to_a + service = described_class.new(account, ids) + expect(service.instance_variable_get(:@conversation_ids).length).to eq(10) + end + end + + context 'when presence_subscribe is disabled' do + let(:presence_enabled) { false } + + it 'does not call presence_subscribe' do + described_class.new(account, [conversation.display_id]).perform + + expect(WebMock).not_to have_requested(:post, presence_subscribe_url) + end + end + + context 'with non-WhatsApp conversations' do + let(:presence_enabled) { true } + let(:web_inbox) { create(:inbox, account: account) } + let(:web_contact) { create(:contact, account: account) } + let(:web_contact_inbox) { create(:contact_inbox, inbox: web_inbox, contact: web_contact) } + let!(:web_conversation) do + create(:conversation, account: account, inbox: web_inbox, contact: web_contact, contact_inbox: web_contact_inbox) + end + + it 'skips non-WhatsApp conversations' do + described_class.new(account, [web_conversation.display_id]).perform + + expect(WebMock).not_to have_requested(:post, presence_subscribe_url) + end + end + + context 'with blank conversation_ids' do + let(:presence_enabled) { true } + + it 'returns early without any HTTP calls' do + described_class.new(account, []).perform + + expect(WebMock).not_to have_requested(:post, presence_subscribe_url) + end + end + end +end diff --git a/spec/services/whatsapp/baileys_handlers/presence_update_spec.rb b/spec/services/whatsapp/baileys_handlers/presence_update_spec.rb new file mode 100644 index 000000000..0da8e5335 --- /dev/null +++ b/spec/services/whatsapp/baileys_handlers/presence_update_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +describe Whatsapp::BaileysHandlers::PresenceUpdate do + let(:webhook_verify_token) { 'valid_token' } + let!(:whatsapp_channel) do + create(:channel_whatsapp, + provider: 'baileys', + provider_config: { webhook_verify_token: webhook_verify_token }, + validate_provider_config: false, + received_messages: false) + end + let(:inbox) { whatsapp_channel.inbox } + let(:lid) { '83749283742' } + let(:phone_number) { '5521999999999' } + + let(:contact) { create(:contact, account: inbox.account, phone_number: "+#{phone_number}", identifier: "#{lid}@lid") } + let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: lid) } + let!(:conversation) do + create(:conversation, account: inbox.account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open) + end + + def perform(data) + params = { webhookVerifyToken: webhook_verify_token, event: 'presence.update', data: data } + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end + + def presence_data_for(lid_jid, status, jid_alt: nil) + data = { + id: lid_jid, + presences: { + lid_jid => { lastKnownPresence: status } + } + } + data[:jidAlt] = jid_alt if jid_alt + data + end + + describe '#process_presence_update' do + context 'with LID-based JID' do + let(:jid) { "#{lid}@lid" } + + it 'dispatches CONVERSATION_TYPING_ON for composing' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with('conversation.typing_on', anything, hash_including(conversation: conversation, user: contact)) + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + + perform(presence_data_for(jid, 'composing')) + end + + it 'dispatches CONVERSATION_RECORDING for recording' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with('conversation.recording', anything, hash_including(conversation: conversation, user: contact)) + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + + perform(presence_data_for(jid, 'recording')) + end + + it 'dispatches CONVERSATION_TYPING_OFF for paused' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with('conversation.typing_off', anything, hash_including(conversation: conversation, user: contact)) + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + + perform(presence_data_for(jid, 'paused')) + end + + it 'dispatches CONVERSATION_TYPING_OFF for available' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with('conversation.typing_off', anything, hash_including(conversation: conversation, user: contact)) + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + + perform(presence_data_for(jid, 'available')) + end + end + + context 'with phone-only contact (no LID yet)' do + let(:phone_only_contact) { create(:contact, account: inbox.account, phone_number: '+5511888888888') } + let(:phone_ci) { create(:contact_inbox, inbox: inbox, contact: phone_only_contact, source_id: '5511888888888') } + + before do + create(:conversation, account: inbox.account, inbox: inbox, contact: phone_only_contact, + contact_inbox: phone_ci, status: :open) + end + + it 'finds contact by phone from jidAlt and dispatches typing' do + new_lid = '999888777666' + jid = "#{new_lid}@lid" + + allow(Rails.configuration.dispatcher).to receive(:dispatch) + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with('conversation.typing_on', anything, hash_including(user: phone_only_contact)) + + perform(presence_data_for(jid, 'composing', jid_alt: '5511888888888:0@s.whatsapp.net')) + end + + it 'consolidates contact_inbox source_id from phone to LID' do + new_lid = '999888777666' + jid = "#{new_lid}@lid" + + allow(Rails.configuration.dispatcher).to receive(:dispatch) + perform(presence_data_for(jid, 'composing', jid_alt: '5511888888888:0@s.whatsapp.net')) + + expect(phone_ci.reload.source_id).to eq(new_lid) + end + end + + it 'does not dispatch when contact is not found' do + unknown_jid = '999999999@lid' + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + expect(Rails.configuration.dispatcher).not_to receive(:dispatch).with('conversation.typing_on', any_args) + + perform(presence_data_for(unknown_jid, 'composing')) + end + + it 'does not dispatch when no active conversation exists' do + conversation.update!(status: :resolved) + jid = "#{lid}@lid" + + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + expect(Rails.configuration.dispatcher).not_to receive(:dispatch).with('conversation.typing_on', any_args) + + perform(presence_data_for(jid, 'composing')) + end + + it 'ignores group JIDs' do + group_data = { + id: '123456789@g.us', + presences: { + "#{lid}@lid" => { lastKnownPresence: 'composing' } + } + } + + expect(Rails.configuration.dispatcher).to receive(:dispatch).with('provider.event_received', any_args) + expect(Rails.configuration.dispatcher).not_to receive(:dispatch).with('conversation.typing_on', any_args) + + perform(group_data) + end + end +end diff --git a/swagger/definitions/resource/webhook.yml b/swagger/definitions/resource/webhook.yml index da5cb112a..f0699ca40 100644 --- a/swagger/definitions/resource/webhook.yml +++ b/swagger/definitions/resource/webhook.yml @@ -14,14 +14,26 @@ properties: items: type: string enum: [ - "conversation_created", - "conversation_status_changed", - "conversation_updated", - "contact_created", - "contact_updated", - "message_created", - "message_updated", - "webwidget_triggered" + conversation_created, + conversation_status_changed, + conversation_updated, + contact_created, + contact_updated, + message_created, + message_incoming, + message_outgoing, + message_updated, + webwidget_triggered, + inbox_created, + inbox_updated, + conversation_typing_on, + conversation_typing_off, + conversation_recording, + provider_event_received, + internal_chat_message_created, + internal_chat_message_updated, + internal_chat_message_deleted, + internal_chat_channel_updated ] description: The list of subscribed events account_id: