feat(whatsapp): delete messages on baileys/zapi providers (#194)

* feat(baileys): implement message deletion functionality

* feat(zapi): add message deletion functionality and corresponding tests

* feat(whatsapp): update message deletion logic for provider compatibility

* feat(whatsapp): enhance message deletion logic to handle missing phone numbers
This commit is contained in:
Gabriel Jablonski 2026-01-24 22:37:50 -03:00 committed by GitHub
parent ec8366aabd
commit 77c90a69ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 323 additions and 1 deletions

View File

@ -27,6 +27,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all
end
delete_message_on_channel
end
def retry
@ -76,6 +77,15 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
message.translations.present? && message.translations[permitted_params[:target_language]].present?
end
def delete_message_on_channel
return unless @conversation.inbox.channel.respond_to?(:delete_message)
return if message.source_id.blank?
@conversation.inbox.channel.delete_message(message, conversation: @conversation)
rescue StandardError => e
Rails.logger.error "Failed to delete message on channel: #{e.message}"
end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages

View File

@ -139,6 +139,19 @@ class Channel::Whatsapp < ApplicationRecord
provider_service.on_whatsapp(phone_number)
end
def delete_message(message, conversation:)
return unless provider_service.respond_to?(:delete_message)
recipient_id = if provider == 'zapi'
conversation.contact.phone_number.presence || conversation.contact.identifier
else
conversation.contact.identifier || conversation.contact.phone_number
end
return if recipient_id.blank?
provider_service.delete_message(recipient_id, message)
end
delegate :setup_channel_provider, to: :provider_service
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service

View File

@ -241,6 +241,27 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
response.parsed_response&.first || { 'jid' => remote_jid, 'exists' => false }
end
def delete_message(recipient_id, message)
@recipient_id = recipient_id
response = HTTParty.delete(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/messages",
headers: api_headers,
body: {
jid: remote_jid,
key: {
id: message.source_id,
remoteJid: remote_jid,
fromMe: message.message_type == 'outgoing'
}
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
private
def provider_url
@ -362,5 +383,6 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
:read_messages,
:unread_message,
:received_messages,
:on_whatsapp
:on_whatsapp,
:delete_message
end

View File

@ -116,6 +116,26 @@ class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseServic
response.parsed_response || { 'exists' => false, 'phone' => nil, 'lid' => nil }
end
def delete_message(recipient_id, message)
return false if recipient_id.blank?
phone = recipient_id.delete('+')
response = HTTParty.delete(
"#{api_instance_path_with_token}/messages",
headers: api_headers,
query: {
messageId: message.source_id,
phone: phone,
owner: message.message_type == 'outgoing'
}
)
raise ProviderUnavailableError unless process_response(response)
true
end
private
def api_instance_path

View File

@ -275,6 +275,88 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when channel supports delete_message' do
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'baileys', account: account, validate_provider_config: false) }
let(:whatsapp_inbox) { whatsapp_channel.inbox }
let(:contact) { create(:contact, account: account, identifier: '+551187654321', phone_number: '+551187654321') }
let(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_inbox, contact: contact) }
let(:whatsapp_conversation) { create(:conversation, inbox: whatsapp_inbox, account: account, contact: contact, contact_inbox: contact_inbox) }
let(:message_with_source) do
create(:message, account: account, conversation: whatsapp_conversation, inbox: whatsapp_inbox, source_id: 'msg_123', message_type: :outgoing)
end
let(:agent) { create(:user, account: account, role: :agent) }
let(:delete_request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages" }
before do
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
end
it 'calls delete_message on the channel' do
delete_stub = stub_request(:delete, delete_request_path)
.with(
headers: { 'Content-Type' => 'application/json', 'x-api-key' => whatsapp_channel.provider_config['api_key'] },
body: hash_including(jid: "#{contact.identifier.delete('+')}@s.whatsapp.net")
)
.to_return(status: 200, body: '{}')
delete "/api/v1/accounts/#{account.id}/conversations/#{whatsapp_conversation.display_id}/messages/#{message_with_source.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(message_with_source.reload.deleted).to be true
expect(delete_stub).to have_been_requested
end
it 'does not fail when channel delete_message raises an error' do
stub_request(:delete, delete_request_path)
.to_return(status: 400, body: 'Provider error')
stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}")
.to_return(status: 200)
allow(Rails.logger).to receive(:error)
delete "/api/v1/accounts/#{account.id}/conversations/#{whatsapp_conversation.display_id}/messages/#{message_with_source.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(message_with_source.reload.deleted).to be true
end
it 'skips channel deletion when message has no source_id' do
message_without_source = create(:message, account: account, conversation: whatsapp_conversation, inbox: whatsapp_inbox, source_id: nil)
delete_stub = stub_request(:delete, delete_request_path).to_return(status: 200, body: '{}')
delete "/api/v1/accounts/#{account.id}/conversations/#{whatsapp_conversation.display_id}/messages/#{message_without_source.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(message_without_source.reload.deleted).to be true
expect(delete_stub).not_to have_been_requested
end
end
context 'when channel does not support delete_message' do
let(:message_with_source) { create(:message, account: account, conversation: conversation, source_id: 'msg_123') }
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, inbox: conversation.inbox, user: agent)
end
it 'skips channel deletion' do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/messages/#{message_with_source.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(message_with_source.reload.deleted).to be true
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:conversation_id/messages/:id/retry' do

View File

@ -378,6 +378,58 @@ RSpec.describe Channel::Whatsapp do
end
end
describe '#delete_message' do
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
let(:contact) { create(:contact, identifier: '+551187654321') }
let(:contact_inbox) { create(:contact_inbox, inbox: channel.inbox, contact: contact) }
let(:conversation) { create(:conversation, inbox: channel.inbox, contact: contact, contact_inbox: contact_inbox) }
let(:message) { create(:message, conversation: conversation, inbox: channel.inbox, source_id: 'msg_123', message_type: :outgoing) }
it 'calls provider service delete_message method for baileys' do
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, delete_message: true)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(provider_double)
channel.delete_message(message, conversation: conversation)
expect(provider_double).to have_received(:delete_message).with(contact.identifier, message)
end
it 'calls provider service delete_message method for zapi with phone_number' do
contact.update!(phone_number: '+551199999999')
channel.update!(provider: 'zapi')
provider_double = instance_double(Whatsapp::Providers::WhatsappZapiService, delete_message: true)
allow(Whatsapp::Providers::WhatsappZapiService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(provider_double)
channel.delete_message(message, conversation: conversation)
expect(provider_double).to have_received(:delete_message).with(contact.phone_number, message)
end
it 'calls provider service delete_message method for zapi falling back to identifier when phone_number is blank' do
channel.update!(provider: 'zapi')
provider_double = instance_double(Whatsapp::Providers::WhatsappZapiService, delete_message: true)
allow(Whatsapp::Providers::WhatsappZapiService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(provider_double)
channel.delete_message(message, conversation: conversation)
expect(provider_double).to have_received(:delete_message).with(contact.identifier, message)
end
it 'does not call method if provider service does not implement it' do
channel.update!(provider: 'whatsapp_cloud')
expect do
channel.delete_message(message, conversation: conversation)
end.not_to raise_error
end
end
describe 'callbacks' do
describe '#disconnect_channel_provider' do
context 'when provider implements the method' do

View File

@ -804,6 +804,75 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
end
describe '#delete_message' do
let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages" }
let(:outgoing_message) { create(:message, inbox: whatsapp_channel.inbox, source_id: 'msg_456', message_type: :outgoing) }
let(:incoming_message) { create(:message, inbox: whatsapp_channel.inbox, source_id: 'msg_789', message_type: :incoming) }
context 'when deleting an outgoing message' do
it 'sends delete request with fromMe true' do
stub_request(:delete, request_path)
.with(
headers: stub_headers(whatsapp_channel),
body: {
jid: test_send_jid,
key: {
id: outgoing_message.source_id,
remoteJid: test_send_jid,
fromMe: true
}
}.to_json
)
.to_return(status: 200, body: '{}')
result = service.delete_message(test_send_phone_number, outgoing_message)
expect(result).to be(true)
end
end
context 'when deleting an incoming message' do
it 'sends delete request with fromMe false' do
stub_request(:delete, request_path)
.with(
headers: stub_headers(whatsapp_channel),
body: {
jid: test_send_jid,
key: {
id: incoming_message.source_id,
remoteJid: test_send_jid,
fromMe: false
}
}.to_json
)
.to_return(status: 200, body: '{}')
result = service.delete_message(test_send_phone_number, incoming_message)
expect(result).to be(true)
end
end
context 'when response is unsuccessful' do
it 'raises ProviderUnavailableError and logs the error' do
stub_request(:delete, request_path)
.with(headers: stub_headers(whatsapp_channel))
.to_return(status: 400, body: 'error message')
stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}")
.to_return(status: 200)
allow(Rails.logger).to receive(:error)
expect do
service.delete_message(test_send_phone_number, outgoing_message)
end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError)
expect(Rails.logger).to have_received(:error).with('error message')
end
end
end
context 'when environment variable BAILEYS_PROVIDER_DEFAULT_URL is set' do
it 'uses the base url from the environment variable' do
stub_const('Whatsapp::Providers::WhatsappBaileysService::DEFAULT_URL', 'http://test.com')

View File

@ -288,6 +288,60 @@ describe Whatsapp::Providers::WhatsappZapiService do
end
end
describe '#delete_message' do
let(:outgoing_message) { create(:message, inbox: whatsapp_channel.inbox, source_id: 'msg_456', message_type: :outgoing) }
let(:incoming_message) { create(:message, inbox: whatsapp_channel.inbox, source_id: 'msg_789', message_type: :incoming) }
context 'when deleting an outgoing message' do
it 'sends delete request with owner true' do
stub_request(:delete, "#{api_instance_path_with_token}/messages")
.with(
headers: stub_headers,
query: { messageId: outgoing_message.source_id, phone: test_send_phone_number, owner: 'true' }
)
.to_return(status: 204, body: '{}')
result = service.delete_message("+#{test_send_phone_number}", outgoing_message)
expect(result).to be(true)
end
end
context 'when deleting an incoming message' do
it 'sends delete request with owner false' do
stub_request(:delete, "#{api_instance_path_with_token}/messages")
.with(
headers: stub_headers,
query: { messageId: incoming_message.source_id, phone: test_send_phone_number, owner: 'false' }
)
.to_return(status: 204, body: '{}')
result = service.delete_message("+#{test_send_phone_number}", incoming_message)
expect(result).to be(true)
end
end
context 'when response is unsuccessful' do
it 'raises ProviderUnavailableError and logs the error' do
stub_request(:delete, "#{api_instance_path_with_token}/messages")
.with(
headers: stub_headers,
query: { messageId: outgoing_message.source_id, phone: test_send_phone_number, owner: 'true' }
)
.to_return(status: 400, body: 'error message')
allow(Rails.logger).to receive(:error)
expect do
service.delete_message("+#{test_send_phone_number}", outgoing_message)
end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError)
expect(Rails.logger).to have_received(:error).with('error message')
end
end
end
describe '#send_message' do
let(:request_path) { "#{api_instance_path_with_token}/send-text" }
let(:result_body) { { 'messageId' => 'msg_123' } }