Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1 UA pattern fallback, already merged). When the request carries an \`X-Chatwoot-Client-Name\` header, \`UserSessionTrackingService\` now prefers the five structured \`X-Chatwoot-*\` headers over the User-Agent for populating the \`user_sessions\` row: | Header | Mapped column | |---|---| | \`X-Chatwoot-Client-Name\` | \`browser_name\` | | \`X-Chatwoot-Client-Version\` | \`browser_version\` | | \`X-Chatwoot-Device-Model\` | \`platform_name\` | | \`X-Chatwoot-Platform-Version\` | \`platform_version\` | | \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` / \`iPad\` / \`Android\`) | When the header is absent or blank, the existing \`Browser.new\` path runs, with the Phase 1 legacy UA fallback still acting as the floor. Real browsers are untouched. A follow-up PR in [chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app) will start sending these headers from the React Native build. Until that ships, this PR is a no-op for production traffic, so it can land independently. Fixes INF-75.
96 lines
2.8 KiB
Ruby
96 lines
2.8 KiB
Ruby
class UserSessionTrackingService
|
|
# CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here.
|
|
LEGACY_MOBILE_UAS = [
|
|
{ match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' },
|
|
{ match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' }
|
|
].freeze
|
|
private_constant :LEGACY_MOBILE_UAS
|
|
|
|
def initialize(user:, request:, client_id:)
|
|
@user = user
|
|
@request = request
|
|
@client_id = client_id
|
|
end
|
|
|
|
def create_or_update!
|
|
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
|
|
session.assign_attributes(session_attributes)
|
|
session.last_activity_at = Time.current
|
|
session.save!
|
|
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
|
|
session
|
|
end
|
|
|
|
def update_activity!
|
|
session = @user.user_sessions.find_by(client_id: @client_id)
|
|
return unless session&.should_update_activity?
|
|
|
|
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
|
|
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 = {
|
|
ip_address: @request.remote_ip,
|
|
user_agent: @request.user_agent,
|
|
browser_name: browser.name,
|
|
browser_version: browser.full_version,
|
|
device_name: browser.device.name,
|
|
platform_name: browser.platform.name,
|
|
platform_version: browser.platform.version
|
|
}
|
|
|
|
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'
|
|
|
|
hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) }
|
|
return attrs unless hit
|
|
|
|
attrs.merge(
|
|
browser_name: 'Chatwoot Mobile',
|
|
browser_version: nil,
|
|
platform_name: hit[:platform],
|
|
platform_version: nil,
|
|
device_name: hit[:device]
|
|
)
|
|
end
|
|
end
|