diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 40a5d8664..67683de86 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -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 diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 7c1bb344e..4998780b9 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -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 diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 1e4d24e02..ca7d83207 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -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 diff --git a/app/services/whatsapp/providers/whatsapp_zapi_service.rb b/app/services/whatsapp/providers/whatsapp_zapi_service.rb index 909f52fb9..1ebd9084e 100644 --- a/app/services/whatsapp/providers/whatsapp_zapi_service.rb +++ b/app/services/whatsapp/providers/whatsapp_zapi_service.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 34464171d..9a29ece34 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -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 diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index e4ae56889..6948b1314 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -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 diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index 12c4560bb..c13f970e6 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -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') diff --git a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb index 20dd06164..de887d678 100644 --- a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb @@ -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' } }