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:
parent
6ea19c0b9f
commit
11e9932e9b
@ -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?
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -204,6 +204,7 @@ const actions = {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
ConversationApi.presenceSubscribe(data.id).catch(() => {});
|
||||
},
|
||||
|
||||
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
46
app/services/conversations/presence_subscribe_service.rb
Normal file
46
app/services/conversations/presence_subscribe_service.rb
Normal 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
|
||||
72
app/services/whatsapp/baileys_handlers/presence_update.rb
Normal file
72
app/services/whatsapp/baileys_handlers/presence_update.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
138
spec/services/whatsapp/baileys_handlers/presence_update_spec.rb
Normal file
138
spec/services/whatsapp/baileys_handlers/presence_update_spec.rb
Normal 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
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user