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"
/>
+
{
- 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: