diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb index d7e6f6efb..b84693b59 100644 --- a/app/services/user_session_tracking_service.rb +++ b/app/services/user_session_tracking_service.rb @@ -31,6 +31,14 @@ class UserSessionTrackingService private def session_attributes + client_headers = mobile_client_headers + if client_headers + return client_headers.merge( + ip_address: @request.remote_ip, + user_agent: @request.user_agent + ) + end + browser = Browser.new(@request.user_agent) attrs = { @@ -46,6 +54,30 @@ class UserSessionTrackingService patch_for_legacy_mobile(attrs) end + def mobile_client_headers + name = @request.headers['X-Chatwoot-Client-Name'] + return nil if name.blank? + + platform = @request.headers['X-Chatwoot-Platform'] + model = @request.headers['X-Chatwoot-Device-Model'] + + { + browser_name: name, + browser_version: @request.headers['X-Chatwoot-Client-Version'], + device_name: device_name_for_icon(platform, model), + platform_name: model, + platform_version: @request.headers['X-Chatwoot-Platform-Version'] + } + end + + def device_name_for_icon(platform, model) + normalized_platform = platform.to_s.downcase + return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad') + return 'iPhone' if normalized_platform == 'ios' + + 'Android' + end + def patch_for_legacy_mobile(attrs) return attrs unless attrs[:browser_name] == 'Unknown Browser' diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb index 2b98b95bf..fb233c48a 100644 --- a/spec/services/user_session_tracking_service_spec.rb +++ b/spec/services/user_session_tracking_service_spec.rb @@ -3,11 +3,14 @@ require 'rails_helper' RSpec.describe UserSessionTrackingService do let(:user) { create(:user) } let(:client_id) { 'client-abc' } + let(:ua) { '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' } + let(:headers) { {} } 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' + user_agent: ua, + remote_ip: '8.8.8.8', + headers: headers ) end let(:service) { described_class.new(user: user, request: request, client_id: client_id) } @@ -49,14 +52,6 @@ RSpec.describe UserSessionTrackingService do 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' } @@ -116,6 +111,113 @@ RSpec.describe UserSessionTrackingService do end end end + + context 'with X-Chatwoot-* structured headers' do + let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' } + + context 'when platform is ios and model is an iPhone' do + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'ios', + 'X-Chatwoot-Platform-Version' => '18.2', + 'X-Chatwoot-Device-Model' => 'iPhone 15 Pro' + } + end + + it 'maps the headers into the session columns', :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 eq('4.7.0') + expect(session.platform_name).to eq('iPhone 15 Pro') + expect(session.platform_version).to eq('18.2') + expect(session.device_name).to eq('iPhone') + expect(session.user_agent).to eq(ua) + end + end + + context 'when platform is ios and model is an iPad' do + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'ios', + 'X-Chatwoot-Platform-Version' => '18.2', + 'X-Chatwoot-Device-Model' => 'iPad Pro 11-inch' + } + end + + it 'sets device_name to iPad so the tablet icon renders', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.platform_name).to eq('iPad Pro 11-inch') + expect(session.device_name).to eq('iPad') + end + end + + context 'when platform is android' do + let(:ua) { 'okhttp/4.9.2' } + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'android', + 'X-Chatwoot-Platform-Version' => '14', + 'X-Chatwoot-Device-Model' => 'Pixel 7 Pro' + } + end + + it 'maps the headers into the session columns', :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 eq('4.7.0') + expect(session.platform_name).to eq('Pixel 7 Pro') + expect(session.platform_version).to eq('14') + expect(session.device_name).to eq('Android') + end + end + + context 'when X-Chatwoot-Client-Name is blank' do + let(:ua) { 'okhttp/4.9.2' } + let(:headers) do + { + 'X-Chatwoot-Client-Name' => '', + 'X-Chatwoot-Platform' => 'android', + 'X-Chatwoot-Device-Model' => 'Pixel 7 Pro' + } + end + + it 'falls through to the legacy UA fallback', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.platform_name).to eq('Android') + expect(session.platform_version).to be_nil + expect(session.device_name).to eq('Android') + end + end + + context 'when no X-Chatwoot-* headers are sent (real browser)' do + let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' } + let(:headers) { {} } + + it 'falls through to the Browser.new path', :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') + end + end + end end describe '#update_activity!' do