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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix: scope typing timer per user instead of per conversation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make presence subscribe best-effort with rescue per channel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gabriel Jablonski 2026-04-13 11:38:11 -03:00 committed by GitHub
parent 6ea19c0b9f
commit 11e9932e9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 613 additions and 35 deletions

View File

@ -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?

View File

@ -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`);
}

View File

@ -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);

View File

@ -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"
/>
<p
v-else-if="isAnyoneTyping"
key="typing-preview"
class="text-green-500 text-sm font-medium my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
{{ typingPreviewText }}
</p>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"

View File

@ -23,6 +23,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'assignee.changed': this.onAssigneeChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
'conversation.recording': this.onRecording,
'conversation.contact_changed': this.onConversationContactChange,
'presence.update': this.onPresenceUpdate,
'contact.deleted': this.onContactDelete,
@ -171,22 +172,33 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onTypingOn = ({ 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/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);
};

View File

@ -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 => {

View File

@ -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",

View File

@ -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"
},

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"
},

View File

@ -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",

View File

@ -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",

View File

@ -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 = () => {
</div>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label>
<div class="flex mb-2 items-center">
<span class="mr-2 text-sm">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PRESENCE_SUBSCRIBE.LABEL') }}
</span>
<Switch id="presenceSubscribe" v-model="presenceSubscribe" />
</div>
</label>
</div>
</template>
<div class="w-full">

View File

@ -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 {
</label>
</div>
</SettingsSection>
<SettingsSection
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PRESENCE_SUBSCRIBE_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PRESENCE_SUBSCRIBE_SUBHEADER')
"
>
<div class="flex items-center gap-2">
<Switch
id="presenceSubscribe"
v-model="presenceSubscribe"
@change="updatePresenceSubscribe"
/>
<label for="presenceSubscribe">
{{
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PRESENCE_SUBSCRIBE_LABEL')
}}
</label>
</div>
</SettingsSection>
</div>
</div>
<div v-else-if="isAWhatsAppZapiChannel">

View File

@ -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',

View File

@ -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],

View File

@ -204,6 +204,7 @@ const actions = {
// Ignore error
}
}
ConversationApi.presenceSubscribe(data.id).catch(() => {});
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {

View File

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

View File

@ -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)

View File

@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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: