module GeoLookup
GeoLookup format โ IRC report cards for geo lookups. Public: format_location_report, format_distance_report, format_km, local_time_for, utc_offset_hours, format_time_report, format_utc_offset, format_near_report, format_isp_report, format_meet_report, format_map_report, format_info_report Depends: tzinfo, irc_format, geo_lookup/{geocode,place} Tests: test/lib/geo_lookup_test.rb, test/plugins/geo_test.rb
GeoLookup geocode โ IP extraction, host validation, IP lookup, channel atlas. Public: extract_ip, cloaked_host?, private_ip?, host_error, lookup_ip, channel_atlas Depends: geocoder Tests: test/lib/geo_lookup_test.rb
GeoLookup isp โ ISP label from geocoder data with hostname fallback. Public: isp_from_hostname, isp_label Depends: geo_lookup/geocode, text_util Tests: test/lib/geo_lookup_isp_test.rb
GeoLookup network โ L3 enrichment, PTR, ASN/hints for !geo info. Public: reverse_ptr, network_hints, build_layer_info Depends: geo_lookup/{geocode,isp,place} Tests: test/lib/geo_lookup_network_test.rb
GeoLookup place โ place/reverse search, result helpers, distance and near-user orchestration. Public: lookup_place, reverse_label, coordinates_for, location_label, timezone_name, distance_km, midpoint_coords, bom_city_from_result, near_entries Depends: geocoder, geo_lookup/geocode Tests: test/lib/geo_lookup_test.rb
Constants
- CLOAKED_HOST
- DEFAULT_GEOIP_DB
- IPV4
- IP_ENCODED_LABEL
- KNOWN_ISP_NAMES
- L1_UNAVAILABLE
- L2_UNAVAILABLE
- LNS_LABEL
- NEAR_RESULT_LIMIT
- NEAR_USER_LIMIT
- PRIVATE_IPV4
- SKIP_ISP_LABELS
- STARLINK_ASN
- STYLE_TITLE
Public Class Methods
Source
# File lib/geo_lookup/place.rb, line 67 def self.bom_city_from_result(result) city = result.city.to_s.strip if result.respond_to?(:city) return city unless city.nil? || city.empty? label = location_label(result) label.split(",").first.to_s.strip end
Source
# File lib/geo_lookup/network.rb, line 45 def self.build_layer_info(ip:, host:, search: Geocoder.method(:search), ptr_resolve: ->(addr) { Resolv.getname(addr) }) ptr = reverse_ptr(ip, resolve: ptr_resolve) result = lookup_ip_any(ip, search: search) l3 = { status: :unavailable, ip: ip, host: host.to_s, ptr: ptr, geo_label: nil, geo_found: false, asn: nil, isp: nil, hints: [] } if result coords = coordinates_for(result) hint_data = network_hints(result, host: host) l3[:asn] = hint_data[:asn] l3[:isp] = hint_data[:isp] l3[:hints] = hint_data[:hints] if coords l3[:status] = :ok l3[:geo_found] = true l3[:geo_label] = location_label(result) else l3[:status] = :partial end end { l1: { status: :unavailable, detail: L1_UNAVAILABLE }, l2: { status: :unavailable, detail: L2_UNAVAILABLE }, l3: l3 } end
Source
# File lib/geo_lookup/geocode.rb, line 65 def self.channel_atlas(users, bot_nick:, search: Geocoder.method(:search)) candidates = users .reject { |user| user.nick.to_s.casecmp?(bot_nick.to_s) } .sort_by { |user| user.nick.to_s.downcase } .take(NEAR_USER_LIMIT) regions = Hash.new(0) cloaked = 0 private_count = 0 unknown = 0 candidates.each do |user| host = user.host.to_s if cloaked_host?(host) cloaked += 1 next end ip = extract_ip(host) unless ip unknown += 1 next end if private_ip?(ip) private_count += 1 next end result = lookup_ip(ip, search: search) unless result unknown += 1 next end label = location_label(result) state = label.split(",").map(&:strip)[1] || label.split(",").first.to_s.strip regions[state.empty? ? label : state] += 1 end { scanned: candidates.length, regions: regions, cloaked: cloaked, private_count: private_count, unknown: unknown } end
Source
# File lib/geo_lookup/geocode.rb, line 28 def self.cloaked_host?(host) host.to_s.match?(CLOAKED_HOST) end
Source
# File lib/geo_lookup.rb, line 35 def self.configure_geocoder! config = { lookup: :nominatim, timeout: 5, units: :km, http_headers: { "User-Agent" => "Rabbot/1.0 (IRC bot; geo plugin)" }, always_raise: [] } if offline_ip_lookup? config[:ip_lookup] = :geoip2 config[:geoip2] = { file: geoip_db_path } else config[:ip_lookup] = :ipapi_com end Geocoder.configure(config) end
Source
# File lib/geo_lookup/place.rb, line 26 def self.coordinates_for(result) coords = result.coordinates return nil if coords.nil? return nil unless coords.length == 2 return nil if coords[0].to_f.zero? && coords[1].to_f.zero? coords end
Source
# File lib/geo_lookup/place.rb, line 57 def self.distance_km(from_coords, to_coords) Geocoder::Calculations.distance_between(from_coords, to_coords, units: :km) end
Source
# File lib/geo_lookup/geocode.rb, line 11 def self.extract_ip(host, resolve: method(:resolve_hostname_to_ip)) host = host.to_s.strip return nil if host.empty? || cloaked_host?(host) return host if host.match?(IPV4) resolve_hostname_to_ip(host, resolve: resolve) end
Source
# File lib/geo_lookup/format.rb, line 23 def self.format_distance_report(from_label:, to_label:, km:, between: false) distance = format_km(km) body = if between "#{distance} between #{from_label} and #{to_label} (as the crow flies)" else "#{distance} from you to #{to_label} (as the crow flies)" end header = IrcFormat.report_header( tag: "GEO", title: "crow-flies distance", style: STYLE_TITLE ) [header, body].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 152 def self.format_info_report(nick:, layers:) header = IrcFormat.report_header( tag: "GEO", title: "#{nick} ยท network info", style: STYLE_TITLE ) l3 = layers[:l3] l3_primary = format_l3_primary_line(l3) l3_secondary = format_l3_secondary_line(l3) l2_line = "L2 โ not visible remotely" l1_line = "L1 โ not visible remotely" body_lines = [l3_primary, l3_secondary, l2_line, l1_line].compact footer = IrcFormat.report_footer("network stack", ip_source_label) ([header] + body_lines + [footer]).join("\n") end
Source
# File lib/geo_lookup/format.rb, line 121 def self.format_isp_report(nick:, result:, host: nil) label = isp_label(result, host: host) header = IrcFormat.report_header(tag: "GEO", title: "#{nick} ยท ISP", style: STYLE_TITLE) body = "#{nick} โ #{label}" footer = IrcFormat.report_footer("approx", ip_source_label) [header, body, footer].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 38 def self.format_km(km) value = km.to_f if value >= 100 "#{value.round.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse} km" elsif value >= 10 "#{value.round(1)} km" else "#{value.round(2)} km" end end
Source
# File lib/geo_lookup/format.rb, line 11 def self.format_location_report(nick:, result:) label = location_label(result) header = IrcFormat.report_header( tag: "GEO", title: "#{nick} ยท #{label}", style: STYLE_TITLE ) body = label footer = IrcFormat.report_footer("approx", ip_source_label) [header, body, footer].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 135 def self.format_map_report(atlas:) header = IrcFormat.report_header(tag: "GEO", title: "channel atlas", style: STYLE_TITLE) if atlas[:regions].empty? body = "No geolocated users (#{atlas[:cloaked]} cloaked, #{atlas[:unknown]} unknown, #{atlas[:scanned]} scanned)" return [header, body].join("\n") end parts = atlas[:regions].sort_by { |_region, count| -count }.map { |region, count| "#{region} (#{count})" } meta = [] meta << "#{atlas[:cloaked]} cloaked" if atlas[:cloaked].positive? meta << "#{atlas[:unknown]} unknown" if atlas[:unknown].positive? body = parts.join(", ") body += " ยท #{meta.join(', ')}" unless meta.empty? footer = IrcFormat.report_footer("#{atlas[:scanned]} scanned") [header, body, footer].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 129 def self.format_meet_report(from_nick:, to_nick:, midpoint_label:, km:) header = IrcFormat.report_header(tag: "GEO", title: "meet in the middle", style: STYLE_TITLE) body = "Halfway between #{from_nick} and #{to_nick}: #{midpoint_label} (#{format_km(km)} from each, as the crow flies)" [header, body].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 99 def self.format_near_report(place:, entries:, scanned:, located:) header = IrcFormat.report_header( tag: "GEO", title: "nearest to #{place}", style: STYLE_TITLE ) if entries.empty? body = "No geolocated users found near #{place} (#{located}/#{scanned} channel users had usable IPs)" return [header, body].join("\n") end lines = entries.first(NEAR_RESULT_LIMIT).each_with_index.map do |entry, index| "#{index + 1}. #{entry[:nick]} โ #{format_km(entry[:km])} (#{entry[:label]})" end footer = IrcFormat.report_footer( "top #{[entries.length, NEAR_RESULT_LIMIT].min}", "#{located}/#{scanned} located" ) ([header] + lines + [footer]).join("\n") end
Source
# File lib/geo_lookup/format.rb, line 74 def self.format_time_report(nick:, result:, clock: -> { Time.now.utc }) local_time = local_time_for(result, clock: clock) return nil unless local_time offset_hours = utc_offset_hours(result, clock: clock) offset_label = offset_hours.nil? ? "local" : format_utc_offset(offset_hours) tz_label = timezone_name(result).to_s.strip tz_suffix = tz_label.empty? ? "" : " (#{tz_label})" header = IrcFormat.report_header( tag: "GEO", title: "#{nick} ยท local time", style: STYLE_TITLE ) body = "#{nick} โ #{local_time.strftime('%H:%M')} #{offset_label}#{tz_suffix}" [header, body].join("\n") end
Source
# File lib/geo_lookup/format.rb, line 92 def self.format_utc_offset(hours) return "UTC" if hours.zero? sign = hours.positive? ? "+" : "" "UTC#{sign}#{hours}" end
Source
# File lib/geo_lookup.rb, line 23 def self.geoip_db_path ENV.fetch("GEOIP_DB_PATH", DEFAULT_GEOIP_DB) end
Source
# File lib/geo_lookup/geocode.rb, line 114 def self.host_error(nick:, host:) if host.to_s.strip.empty? "Could not read host for #{nick} โ try again after they speak in channel" elsif cloaked_host?(host) "#{nick}'s host is cloaked โ cannot geolocate" elsif extract_ip(host).nil? "#{nick}'s host is not a usable IP โ cannot geolocate" elsif private_ip?(extract_ip(host)) "#{nick} has a private/local IP โ cannot geolocate" else "Could not geolocate #{nick}" end end
Source
# File lib/geo_lookup.rb, line 31 def self.ip_source_label offline_ip_lookup? ? "MaxMind GeoLite2" : "IP geolocation" end
Source
# File lib/geo_lookup/isp.rb, line 36 def self.isp_from_hostname(host) host = host.to_s.strip return nil if host.empty? || cloaked_host?(host) || host.match?(IPV4) candidates = hostname_isp_candidates(host) return nil if candidates.empty? known = known_isp_name(candidates) return known if known TextUtil.title_words(candidates.max_by(&:length)) end
Source
# File lib/geo_lookup/isp.rb, line 49 def self.isp_label(result, host: nil) data = result.data %w[isp org autonomous_system_organization as].each do |key| value = data[key] || data[key.to_sym] return value.to_s.strip unless value.nil? || value.to_s.strip.empty? end from_host = isp_from_hostname(host) return from_host unless from_host.nil? || from_host.empty? "unknown ISP" end
Source
# File lib/geo_lookup/format.rb, line 49 def self.local_time_for(result, clock: -> { Time.now.utc }) offset = result.data["offset"] || result.data[:offset] return clock.call.getlocal(offset.to_i) unless offset.nil? tz_name = timezone_name(result).to_s.strip return nil if tz_name.empty? TZInfo::Timezone.get(tz_name).to_local(clock.call) rescue TZInfo::InvalidTimezoneIdentifier nil end
Source
# File lib/geo_lookup/place.rb, line 44 def self.location_label(result) address = result.address.to_s.strip if result.respond_to?(:address) return address unless address.nil? || address.empty? parts = [] parts << result.city if result.respond_to?(:city) && !result.city.to_s.strip.empty? parts << result.state_code if result.respond_to?(:state_code) && !result.state_code.to_s.strip.empty? parts << result.state if parts.length < 2 && result.respond_to?(:state) && !result.state.to_s.strip.empty? parts << result.country if result.respond_to?(:country) && !result.country.to_s.strip.empty? label = parts.map(&:to_s).map(&:strip).reject(&:empty?).join(", ") label.empty? ? "unknown location" : label end
Source
# File lib/geo_lookup/geocode.rb, line 36 def self.lookup_ip(ip, search: Geocoder.method(:search)) return nil if ip.to_s.strip.empty? return nil if private_ip?(ip) results = search.call(ip) result = results.first return nil unless result coords = coordinates_for(result) return nil unless coords result rescue StandardError nil end
Source
# File lib/geo_lookup/geocode.rb, line 52 def self.lookup_ip_any(ip, search: Geocoder.method(:search)) return nil if ip.to_s.strip.empty? return nil if private_ip?(ip) results = search.call(ip) result = results.first return nil unless result result rescue StandardError nil end
Source
# File lib/geo_lookup/place.rb, line 10 def self.lookup_place(place, search: Geocoder.method(:search)) text = place.to_s.strip return nil if text.empty? results = search.call(text) result = results.first return nil unless result coords = coordinates_for(result) return nil unless coords result rescue StandardError nil end
Source
# File lib/geo_lookup/place.rb, line 61 def self.midpoint_coords(from_coords, to_coords) lat = (from_coords[0].to_f + to_coords[0].to_f) / 2.0 lon = (from_coords[1].to_f + to_coords[1].to_f) / 2.0 [lat, lon] end
Source
# File lib/geo_lookup/place.rb, line 85 def self.near_entries(users, place_coords, bot_nick:, search: Geocoder.method(:search)) candidates = users .reject { |user| user.nick.to_s.casecmp?(bot_nick.to_s) } .sort_by { |user| user.nick.to_s.downcase } .take(NEAR_USER_LIMIT) located = candidates.filter_map do |user| ip = extract_ip(user.host) next unless ip result = lookup_ip(ip, search: search) next unless result coords = coordinates_for(result) next unless coords { nick: user.nick, km: distance_km(coords, place_coords), label: location_label(result) } end located.sort_by { |entry| entry[:km] } end
Source
# File lib/geo_lookup/network.rb, line 25 def self.network_hints(result, host: nil) data = result.data hints = [] asn = extract_asn_label(data) isp = isp_label(result, host: host) check_text = [asn, isp, data["org"], data[:org]].compact.join(" ") asn_num = data["autonomous_system_number"] || data[:autonomous_system_number] if check_text.match?(/starlink|spacex/i) || asn_num.to_s == STARLINK_ASN.to_s hints << "satellite" end hints << "mobile" if truthy_data?(data["mobile"] || data[:mobile]) hints << "hosting" if truthy_data?(data["hosting"] || data[:hosting]) hints << "proxy" if truthy_data?(data["proxy"] || data[:proxy]) { asn: asn, isp: isp, hints: hints.uniq } end
Source
# File lib/geo_lookup.rb, line 27 def self.offline_ip_lookup? File.file?(geoip_db_path) end
Source
# File lib/geo_lookup/geocode.rb, line 32 def self.private_ip?(ip) ip.to_s.match?(PRIVATE_IPV4) end
Source
# File lib/geo_lookup/geocode.rb, line 19 def self.resolve_hostname_to_ip(hostname, resolve: ->(name) { Resolv.getaddress(name) }) ip = resolve.call(hostname).to_s.strip return ip if ip.match?(IPV4) nil rescue Resolv::ResolvError, SocketError nil end
Source
# File lib/geo_lookup/place.rb, line 75 def self.reverse_label(coords, search: Geocoder.method(:search)) results = search.call(coords) result = results.first return "unknown location" unless result location_label(result) rescue StandardError "unknown location" end
Source
# File lib/geo_lookup/network.rb, line 16 def self.reverse_ptr(ip, resolve: ->(addr) { Resolv.getname(addr) }) name = resolve.call(ip).to_s.strip return nil if name.empty? name rescue Resolv::ResolvError, SocketError nil end
Source
# File lib/geo_lookup/place.rb, line 35 def self.timezone_name(result) data = result.data name = data["timezone"] || data[:timezone] return name.to_s if name && !name.to_s.strip.empty? location = data["location"] || data[:location] || {} location["time_zone"] || location[:time_zone] || "" end
Source
# File lib/geo_lookup/format.rb, line 61 def self.utc_offset_hours(result, clock: -> { Time.now.utc }) offset = result.data["offset"] || result.data[:offset] return offset.to_i / 3600 unless offset.nil? tz_name = timezone_name(result).to_s.strip return nil if tz_name.empty? period = TZInfo::Timezone.get(tz_name).period_for_utc(clock.call) period.utc_offset / 3600 rescue TZInfo::InvalidTimezoneIdentifier nil end