Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts

This commit is contained in:
Muhsin Keloth
2026-06-22 12:57:08 +04:00
committed by GitHub
224 changed files with 9320 additions and 7543 deletions
@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Conversations::DeleteService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:ip) { '127.0.0.1' }
let(:service) { described_class.new(conversation: conversation, user: user, ip: ip) }
context 'when deleting an email conversation' do
let(:inbox) { create(:channel_email, :imap_email, account: account).inbox }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let!(:incoming_message) { create(:message, account: account, inbox: inbox, conversation: conversation, source_id: 'incoming@example.com') }
let(:deleted_message_tracker) { instance_double(Imap::DeletedMessageTracker, record: true) }
before do
allow(Imap::DeletedMessageTracker).to receive(:new).with(inbox: inbox).and_return(deleted_message_tracker)
end
it 'records incoming message source ids and enqueues the deletion job' do
expect { service.perform }.to have_enqueued_job(DeleteObjectJob).with(conversation, user, ip)
expect(deleted_message_tracker).to have_received(:record).with([incoming_message.source_id])
end
end
context 'when deleting a non-email conversation' do
let(:conversation) { create(:conversation, account: account) }
it 'enqueues the deletion job without recording message source ids' do
expect(Imap::DeletedMessageTracker).not_to receive(:new)
expect { service.perform }.to have_enqueued_job(DeleteObjectJob).with(conversation, user, ip)
end
end
end
@@ -119,6 +119,28 @@ RSpec.describe Imap::FetchEmailService do
end
end
it 'does not return recently deleted emails' do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
redis_key = format(Redis::RedisKeys::IMAP_DELETED_MESSAGE,
inbox_id: imap_email_channel.inbox.id,
message_id_digest: Digest::SHA256.hexdigest(email_object.message_id))
Imap::DeletedMessageTracker.new(inbox: imap_email_channel.inbox).record([email_object.message_id])
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
expect(result).to be_empty
expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
ensure
Redis::Alfred.delete(redis_key) if redis_key
end
end
it 'does not count emails without message ids toward the sync limit' do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
@@ -511,6 +511,60 @@ describe Twilio::IncomingMessageService do
end
end
describe 'When the incoming WhatsApp message has CTWA referral parameters' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'stores normalized referral attributes on the message' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Hallo! Kann ich hierzu mehr Informationen erhalten?',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG',
ReferralSourceId: '120237244350960485',
ReferralSourceUrl: 'https://fb.me/4tBfhWhjr',
ReferralSourceType: 'ad',
ReferralHeadline: 'German citizenship lawyer',
ReferralBody: 'Fast-track your German citizenship',
ReferralMediaId: '',
ReferralNumMedia: '0'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes['referral']).to eq(
'ctwa_clid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'source_id' => '120237244350960485',
'source_url' => 'https://fb.me/4tBfhWhjr',
'source_type' => 'ad',
'headline' => 'German citizenship lawyer',
'body' => 'Fast-track your German citizenship',
'num_media' => '0'
)
end
it 'does not add referral attributes when ReferralSourceId is absent' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Regular WhatsApp message',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes).not_to have_key('referral')
end
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
@@ -0,0 +1,151 @@
require 'rails_helper'
RSpec.describe UserSessionTrackingService do
let(:user) { create(:user) }
let(:client_id) { 'client-abc' }
let(:request) do
instance_double(
ActionDispatch::Request,
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
remote_ip: '8.8.8.8'
)
end
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
describe '#create_or_update!' do
it 'creates a new UserSession with the right client_id and timestamps' do
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.client_id).to eq(client_id)
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'populates request and browser metadata synchronously', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.ip_address).to eq('8.8.8.8')
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
it 'does not call IpLookupService synchronously' do
expect(IpLookupService).not_to receive(:new)
service.create_or_update!
end
it 'enqueues UserSessionIpLookupJob to backfill geo data' do
expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob)
end
it 'updates an existing session when client_id matches' do
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
expect(existing.reload.ip_address).to eq('8.8.8.8')
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
end
context 'with a Chatwoot Mobile legacy User-Agent' do
let(:request) do
instance_double(
ActionDispatch::Request,
user_agent: ua,
remote_ip: '8.8.8.8'
)
end
context 'when the UA is okhttp (Android Chatwoot Mobile)' do
let(:ua) { 'okhttp/4.9.2' }
it 'labels the session as Chatwoot Mobile on Android', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Chatwoot Mobile')
expect(session.browser_version).to be_nil
expect(session.platform_name).to eq('Android')
expect(session.platform_version).to be_nil
expect(session.device_name).to eq('Android')
expect(session.user_agent).to eq(ua)
end
end
context 'when the UA is CFNetwork (iOS Chatwoot Mobile)' do
let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' }
it 'labels the session as Chatwoot Mobile on iPhone', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Chatwoot Mobile')
expect(session.browser_version).to be_nil
expect(session.platform_name).to eq('iPhone')
expect(session.platform_version).to be_nil
expect(session.device_name).to eq('iPhone')
expect(session.user_agent).to eq(ua)
end
end
context 'when the UA is a real browser (Firefox on Linux)' do
let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' }
it 'does not override the Browser-derived metadata', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Firefox')
expect(session.platform_name).to eq('Generic Linux')
expect(session.device_name).not_to eq('Android')
expect(session.device_name).not_to eq('iPhone')
end
end
context 'when the UA is unknown but does not match any mobile pattern' do
let(:ua) { 'curl/8.4.0' }
it 'leaves the Unknown labels untouched', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Unknown Browser')
expect(session.platform_name).to eq('Unknown')
expect(session.device_name).to eq('Unknown')
end
end
end
end
describe '#update_activity!' do
it 'does nothing when no session exists for the client_id' do
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
end
it 'does nothing when the session was recently active' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
before_ts = session.last_activity_at
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
end
it 'bumps last_activity_at when the session is stale' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'bumps last_activity_at when last_activity_at is nil' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
end
end
@@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(
status: 200,
@@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
end
@@ -59,6 +59,41 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
context 'when document attachment includes an accented filename' do
let(:document_params) do
{
phone_number: whatsapp_channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
messages: [{
from: '2423423243',
document: {
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
mime_type: 'application/pdf',
filename: 'Currículum café.pdf',
caption: 'My résumé'
},
timestamp: '1664799904', type: 'document'
}]
}
}]
}]
}.with_indifferent_access
end
it 'preserves the original filename from the payload' do
stub_media_url_request
stub_sample_png_request
described_class.new(inbox: whatsapp_channel.inbox, params: document_params).perform
attachment = whatsapp_channel.inbox.messages.first.attachments.first
expect(attachment.file.filename.to_s).to eq('Currículum café.pdf')
end
end
context 'when invalid attachment message params' do
let(:error_params) do
{
@@ -199,6 +234,88 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
context 'when message contains referral data' do
let(:referral_params) do
{
phone_number: whatsapp_channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Mom' }, wa_id: '255718573302', user_id: 'TZ.1040042605869930' }],
messages: [{
referral: {
source_url: 'https://fb.me/3TYpooaRT',
source_id: '52558118838064',
source_type: 'ad',
body: 'washa data tu',
headline: 'Diana Digital',
media_type: 'video',
video_url: 'https://www.facebook.com/reel/1438165771395493/',
thumbnail_url: 'https://scontent.xx.fbcdn.net/sample.jpg',
ctwa_clid: 'AfhcQdP2E4A8wWpeb1FqUzUi',
welcome_message: {
text: 'Hi! Please let us know how we can help you.'
}
},
from: '255718573302',
from_user_id: 'TZ.1040042605869930',
id: 'wamid.CTWA_REFERRAL_MESSAGE',
timestamp: '1780649766',
text: { body: 'Hello nielekeze' },
type: 'text'
}]
}
}]
}]
}.with_indifferent_access
end
it 'stores the referral payload in message content attributes' do
described_class.new(inbox: whatsapp_channel.inbox, params: referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Hello nielekeze')
expect(message.content_attributes['referral']).to include(
'source_url' => 'https://fb.me/3TYpooaRT',
'source_id' => '52558118838064',
'source_type' => 'ad',
'body' => 'washa data tu',
'headline' => 'Diana Digital',
'media_type' => 'video',
'video_url' => 'https://www.facebook.com/reel/1438165771395493/',
'thumbnail_url' => 'https://scontent.xx.fbcdn.net/sample.jpg',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi',
'welcome_message' => { 'text' => 'Hi! Please let us know how we can help you.' }
)
end
it 'preserves the referral payload when the message contains contacts' do
contacts_referral_params = referral_params.deep_dup
parent_message = contacts_referral_params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
parent_message[:type] = 'contacts'
parent_message.delete(:text)
parent_message[:contacts] = [{
name: {
formatted_name: 'Diana Digital',
first_name: 'Diana',
last_name: 'Digital'
},
phones: [{ phone: '+255718573302' }]
}]
described_class.new(inbox: whatsapp_channel.inbox, params: contacts_referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Diana Digital')
expect(message.content_attributes['referral']).to include(
'source_id' => '52558118838064',
'headline' => 'Diana Digital',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi'
)
end
end
context 'when message is a reply (has context)' do
let(:reply_params) do
{
@@ -43,7 +43,7 @@ describe Whatsapp::WebhookSetupService do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
@@ -51,7 +51,8 @@ describe Whatsapp::WebhookSetupService do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
smb_message_echoes])
service.perform
end
end
@@ -65,14 +66,15 @@ describe Whatsapp::WebhookSetupService do
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
end
it 'does NOT register phone, but sets up webhook' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).not_to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
smb_message_echoes])
service.perform
end
end
@@ -88,7 +90,7 @@ describe Whatsapp::WebhookSetupService do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
@@ -96,7 +98,8 @@ describe Whatsapp::WebhookSetupService do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
smb_message_echoes])
service.perform
end
end
@@ -112,7 +115,7 @@ describe Whatsapp::WebhookSetupService do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
@@ -120,7 +123,8 @@ describe Whatsapp::WebhookSetupService do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
smb_message_echoes])
service.perform
end
end
@@ -279,14 +283,15 @@ describe Whatsapp::WebhookSetupService do
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
end
it 'successfully reauthorizes with new access token' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).not_to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token',
subscribed_fields: %w[messages smb_message_echoes])
service_reauth.perform
end
end
@@ -294,7 +299,7 @@ describe Whatsapp::WebhookSetupService do
it 'uses the existing webhook verify token during reauthorization' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token')
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes])
service_reauth.perform
end
end
@@ -308,7 +313,7 @@ describe Whatsapp::WebhookSetupService do
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
end
it 'completes successfully without errors' do