Feat: improve read receipts (#56)

* feat: add store in additional_attributes to conversation model

* feat: set default mark_as_read to true in additional_attributes for conversation params

* fix: remove mark_as_read from additional_attributes in conversation

* refactor: move message read logic to mark_messages_as_read method in ConversationsController

* feat: add 'Mark messages as read' option to WhatsApp channel settings

* feat: add 'mark_as_read' option in factorie  for WhatsApp channel

* feat: integrate Checkbox component for 'mark as read' option in WhatsApp settings

* fix: ensure 'mark_as_read' option is included in channel creation payload

* feat: add 'Mark as read' settings for WhatsApp inbox, including UI and state management

* chore: remove redundant content update test from IncomingMessageBaileysService spec

* feat: update 'Mark as read' label to 'Read receipts' and replace Checkbox with Switch component in WhatsApp settings

* fix: handle potential nil value for 'mark_as_read' in provider config

* feat: refactor provider config to streamline channel creation with 'mark_as_read' option

* feat: add test for MESSAGE_READ event dispatch in update_last_seen action

* fix: update subheader for 'Mark as read' setting to read receipt behavior in WhatsApp

* feat: update mark_messages_as_read behavior to handle false value in provider config

* chore: update label for 'Mark as read' option

* feat: update update_last_seen to send WhatsApp read receipt for WhatsApp channels

* test: remove MESSAGE_READ event dispatch tests in wrong file

* feat: enhance update_last_seen behavior for WhatsApp channel to conditionally dispatch messages.read event

* feat: update update_last_seen to dispatch messages.read event

* test: refactor update_last_seen tests

* chore: refactor to ensure provider_service responds to read_messages before ensure the mark_as_read provider config

* test: enhance #read_messages with provider config mark_as_read expected behaviors

* chore: clarify test names and remove useless expect

---------

Co-authored-by: gabrieljablonski <contact@gabrieljablonski.com>
This commit is contained in:
Cayo P. R. Oliveira 2025-06-03 23:28:15 -03:00 committed by GitHub
parent 8ec086f8d0
commit a3effacc21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 134 additions and 24 deletions

View File

@ -110,9 +110,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
last_seen_at: @conversation.agent_last_seen_at)
dispatch_messages_read_event if assignee?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@ -206,6 +204,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def assignee?
@conversation.assignee_id? && Current.user == @conversation.assignee
end
def dispatch_messages_read_event
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
last_seen_at: @conversation.agent_last_seen_at)
end
end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')

View File

@ -267,7 +267,10 @@
"PROVIDER_URL": {
"LABEL": "Provider URL",
"PLACEHOLDER": "If provider is not running locally, please provide the URL",
"ERROR":"Please enter a valid URL"
"ERROR": "Please enter a valid URL"
},
"MARK_AS_READ": {
"LABEL": "Send read receipts"
},
"ADVANCED_OPTIONS": "Advanced options",
"BAILEYS": {
@ -558,7 +561,10 @@
"WHATSAPP_PROVIDER_URL_SUBHEADER": "If the provider is not running locally, please provide the URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Enter the provider URL",
"WHATSAPP_PROVIDER_URL_ERROR": "Please enter a valid URL",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
"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"
},
"HELP_CENTER": {
"LABEL": "Help Center",

View File

@ -267,7 +267,10 @@
"PROVIDER_URL": {
"LABEL": "URL do provedor",
"PLACEHOLDER": "Se o provedor não está rodando localmente, por favor, insira a URL do provedor",
"ERROR":"Por favor, insira uma URL válida"
"ERROR": "Por favor, insira uma URL válida"
},
"MARK_AS_READ": {
"LABEL": "Enviar confirmações de leitura"
},
"ADVANCED_OPTIONS": "Opções avançadas",
"BAILEYS": {
@ -558,7 +561,10 @@
"WHATSAPP_PROVIDER_URL_SUBHEADER": "Se o provedor não estiver rodando localmente, por favor, forneça a URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Digite a URL do provedor",
"WHATSAPP_PROVIDER_URL_ERROR": "Por favor, insira uma URL válida",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat",
"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"
},
"HELP_CENTER": {
"LABEL": "Centro de Ajuda",

View File

@ -8,10 +8,13 @@ import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
NextButton,
// eslint-disable-next-line vue/no-reserved-component-names
Switch,
},
setup() {
return { v$: useVuelidate() };
@ -23,6 +26,7 @@ export default {
apiKey: '',
providerUrl: '',
showAdvancedOptions: false,
markAsRead: true,
};
},
computed: {
@ -47,6 +51,15 @@ export default {
}
try {
const providerConfig = {
mark_as_read: this.markAsRead,
};
if (this.apiKey || this.providerUrl) {
providerConfig.api_key = this.apiKey;
providerConfig.url = this.providerUrl;
}
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
@ -55,13 +68,7 @@ export default {
type: 'whatsapp',
phone_number: this.phoneNumber,
provider: 'baileys',
provider_config:
this.apiKey || this.providerUrl
? {
api_key: this.apiKey,
url: this.providerUrl,
}
: {},
provider_config: providerConfig,
},
}
);
@ -159,6 +166,17 @@ export default {
</span>
</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.MARK_AS_READ.LABEL') }}
</span>
<Switch id="markAsRead" v-model="markAsRead" />
</div>
</label>
</div>
</template>
<div class="w-full">

View File

@ -10,6 +10,7 @@ import { requiredIf } from '@vuelidate/validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import WhatsappBaileysLinkDeviceModal from '../components/WhatsappBaileysLinkDeviceModal.vue';
import InboxName from '../../../../../components/widgets/InboxName.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
@ -19,6 +20,8 @@ export default {
NextButton,
WhatsappBaileysLinkDeviceModal,
InboxName,
// eslint-disable-next-line vue/no-reserved-component-names
Switch,
},
mixins: [inboxMixin],
props: {
@ -36,6 +39,7 @@ export default {
whatsAppInboxAPIKey: '',
whatsAppProviderUrl: '',
showBaileysLinkDeviceModal: false,
markAsRead: true,
};
},
validations() {
@ -57,6 +61,7 @@ export default {
methods: {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.markAsRead = this.inbox.provider_config.mark_as_read ?? true;
},
handleHmacFlag() {
this.updateInbox();
@ -114,6 +119,24 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateWhatsAppMarkAsRead() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
mark_as_read: this.markAsRead,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
onOpenBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = true;
},
@ -383,6 +406,23 @@ export default {
</NextButton>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_SUBHEADER')
"
>
<div class="flex items-center gap-2">
<Switch
id="markAsRead"
v-model="markAsRead"
@change="updateWhatsAppMarkAsRead"
/>
<label for="markAsRead">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_LABEL') }}
</label>
</div>
</SettingsSection>
</div>
</div>
</template>

View File

@ -93,6 +93,8 @@ class Channel::Whatsapp < ApplicationRecord
def read_messages(messages, conversation:)
return unless provider_service.respond_to?(:read_messages)
# NOTE: This is the default behavior, so `mark_as_read` being `nil` is the same as `true`.
return if provider_config&.dig('mark_as_read') == false
provider_service.read_messages(conversation.contact.phone_number, messages)
end

View File

@ -688,18 +688,33 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.assignee_last_seen_at).not_to be_nil
end
it 'dispatches messages.read event' do
it 'dispatches messages.read event when user is assignee' do
freeze_time
conversation.update!(agent_last_seen_at: 1.hour.ago)
previous_agent_last_seen_at = 1.hour.ago
conversation.update!(agent_last_seen_at: previous_agent_last_seen_at, assignee: agent)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
.with(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: conversation.agent_last_seen_at)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
expect(Rails.configuration.dispatcher)
.to have_received(:dispatch)
.with(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: previous_agent_last_seen_at)
end
it 'does not dispatch messages.read event when user is not assignee' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
end

View File

@ -77,7 +77,7 @@ FactoryBot.define do
channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config
if channel_whatsapp.provider == 'baileys'
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'provider_url' => 'https://baileys.api',
'phone_number_id' => '123456789' })
'phone_number_id' => '123456789', 'mark_as_read' => true })
elsif channel_whatsapp.provider == 'whatsapp_cloud'
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789',
'business_account_id' => '123456789' })

View File

@ -121,7 +121,9 @@ RSpec.describe Channel::Whatsapp do
end
describe '#read_messages' do
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
let(:channel) do
create(:channel_whatsapp, provider: 'baileys', provider_config: { mark_as_read: true }, validate_provider_config: false, sync_templates: false)
end
let(:conversation) { create(:conversation) }
let(:message) { create(:message) }
@ -137,13 +139,30 @@ RSpec.describe Channel::Whatsapp do
expect(provider_double).to have_received(:read_messages)
end
it 'does not call method if provider service does not implement it' do
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new)
it 'call method when the provider config mark_as_read is nil' do
channel.update!(provider_config: {})
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil)
allow(provider_double).to receive(:read_messages).with([message], conversation.contact.phone_number)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(provider_double)
channel.read_messages([message], conversation: conversation)
expect(provider_double).to have_received(:read_messages)
end
it 'does not call method if provider service does not implement it' do
channel.update!(provider: 'whatsapp_cloud')
expect do
channel.read_messages([message], conversation: conversation)
end.not_to raise_error
end
it 'does not call method if provider config mark_as_read is false' do
channel.update!(provider_config: { mark_as_read: false })
expect do
channel.read_messages([message], conversation: conversation)
end.not_to raise_error