Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
691
src/jarvis/utils/location.py
Normal file
691
src/jarvis/utils/location.py
Normal file
@@ -0,0 +1,691 @@
|
||||
from __future__ import annotations
|
||||
import socket
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import sys
|
||||
from ..debug import debug_log
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
import geoip2.errors
|
||||
GEOIP2_AVAILABLE = True
|
||||
except ImportError:
|
||||
GEOIP2_AVAILABLE = False
|
||||
except Exception as e:
|
||||
# Catch any native/DLL loading errors
|
||||
GEOIP2_AVAILABLE = False
|
||||
if sys.platform == 'win32':
|
||||
print(f" ⚠️ geoip2 import failed: {e}", flush=True)
|
||||
|
||||
try:
|
||||
import miniupnpc
|
||||
MINIUPNPC_AVAILABLE = True
|
||||
except ImportError:
|
||||
MINIUPNPC_AVAILABLE = False
|
||||
except Exception as e:
|
||||
# Catch any native/DLL loading errors (common on Windows)
|
||||
MINIUPNPC_AVAILABLE = False
|
||||
if sys.platform == 'win32':
|
||||
print(f" ⚠️ miniupnpc import failed: {e}", flush=True)
|
||||
|
||||
# Session flag to show location warning only once per session
|
||||
_location_warning_shown = False
|
||||
|
||||
# Simple in-memory caches (module scoped)
|
||||
# Cache for location lookups keyed by final resolved IP -> location_info dict
|
||||
_location_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Cache for CGNAT OpenDNS public IP resolution attempts keyed by original CGNAT IP.
|
||||
# Value is tuple: (timestamp, resolved_public_ip or None). We avoid re-querying OpenDNS
|
||||
# more than once per hour for the same CGNAT IP. This respects user privacy by
|
||||
# minimising external DNS queries.
|
||||
_cgnat_resolution_cache: Dict[str, tuple[datetime, Optional[str]]] = {}
|
||||
|
||||
# TTL for CGNAT OpenDNS resolution attempts
|
||||
_CGNAT_RESOLUTION_TTL = timedelta(hours=1)
|
||||
|
||||
# Disk cache paths (share directory with geoip DB for locality)
|
||||
def _cache_base_dir() -> Path:
|
||||
return Path.home() / ".local" / "share" / "jarvis"
|
||||
|
||||
_LOCATION_CACHE_FILE = _cache_base_dir() / "location_cache.json"
|
||||
_CGNAT_CACHE_FILE = _cache_base_dir() / "cgnat_cache.json"
|
||||
|
||||
_cache_lock = threading.RLock()
|
||||
|
||||
def _load_disk_caches() -> None:
|
||||
"""Load caches from disk into memory (best-effort)."""
|
||||
try:
|
||||
base = _cache_base_dir()
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
with _cache_lock:
|
||||
# Location cache
|
||||
try:
|
||||
if _LOCATION_CACHE_FILE.exists():
|
||||
with _LOCATION_CACHE_FILE.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
# Expect mapping ip -> {data: {...}, ts: iso}
|
||||
for ip, payload in raw.items():
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
ts_str = payload.get("ts") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict) or not ts_str:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
continue
|
||||
# TTL for location cache can vary; default 60 minutes (aligned with config default).
|
||||
ttl_minutes = payload.get("ttl") if isinstance(payload, dict) else None
|
||||
try:
|
||||
ttl_minutes = int(ttl_minutes)
|
||||
except Exception:
|
||||
ttl_minutes = 60
|
||||
if now - ts < timedelta(minutes=ttl_minutes):
|
||||
_location_cache[ip] = data
|
||||
except Exception:
|
||||
pass
|
||||
# CGNAT resolution cache
|
||||
try:
|
||||
if _CGNAT_CACHE_FILE.exists():
|
||||
with _CGNAT_CACHE_FILE.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
for cgnat_ip, payload in raw.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
ts_str = payload.get("ts")
|
||||
resolved = payload.get("resolved")
|
||||
if not ts_str:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
continue
|
||||
if now - ts < _CGNAT_RESOLUTION_TTL:
|
||||
_cgnat_resolution_cache[cgnat_ip] = (ts, resolved)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _persist_disk_caches(location_cache_minutes: int = 60) -> None:
|
||||
"""Persist in-memory caches to disk (best-effort)."""
|
||||
with _cache_lock:
|
||||
try:
|
||||
base = _cache_base_dir()
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
# Location cache serialisation
|
||||
try:
|
||||
loc_out = {}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for ip, data in _location_cache.items():
|
||||
loc_out[ip] = {"data": data, "ts": now, "ttl": int(location_cache_minutes)}
|
||||
with _LOCATION_CACHE_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(loc_out, f)
|
||||
except Exception:
|
||||
pass
|
||||
# CGNAT cache
|
||||
try:
|
||||
cgnat_out = {}
|
||||
for ip, (ts, resolved) in _cgnat_resolution_cache.items():
|
||||
cgnat_out[ip] = {"ts": ts.isoformat(), "resolved": resolved}
|
||||
with _CGNAT_CACHE_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cgnat_out, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load caches on module import
|
||||
_load_disk_caches()
|
||||
|
||||
|
||||
def _get_local_network_ip() -> Optional[str]:
|
||||
"""
|
||||
Get the local network IP address without making external calls.
|
||||
This respects privacy by not contacting third-party services.
|
||||
|
||||
Note: This returns the local IP, not the public IP, so geolocation
|
||||
will only work if the user manually configures their public IP.
|
||||
"""
|
||||
try:
|
||||
# Using context manager ensures socket is always closed
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
# Connect to a non-routable address to determine local IP
|
||||
# This doesn't actually send any data
|
||||
s.connect(("10.254.254.254", 80))
|
||||
return s.getsockname()[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_external_ip_via_upnp() -> Optional[str]:
|
||||
"""
|
||||
Get external IP address by querying the local router via UPnP.
|
||||
This is privacy-friendly as it only communicates with your local router.
|
||||
|
||||
Returns:
|
||||
External IP address if successful, None otherwise.
|
||||
"""
|
||||
if not MINIUPNPC_AVAILABLE:
|
||||
return None
|
||||
|
||||
try:
|
||||
upnp = miniupnpc.UPnP()
|
||||
upnp.discoverdelay = 200 # milliseconds
|
||||
|
||||
# Discover UPnP devices
|
||||
device_count = upnp.discover()
|
||||
if device_count == 0:
|
||||
return None
|
||||
|
||||
# Select the Internet Gateway Device
|
||||
upnp.selectigd()
|
||||
|
||||
# Get the external IP address
|
||||
external_ip = upnp.externalipaddress()
|
||||
|
||||
# Validate the IP address and ensure it's not private
|
||||
if external_ip and not _is_private_ip(external_ip) and "." in external_ip:
|
||||
return external_ip
|
||||
|
||||
except Exception:
|
||||
# UPnP might not be supported or enabled
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_private_ip(ip: str) -> bool:
|
||||
"""Check if an IP address is private/local or special-use (non-geolocatable)."""
|
||||
if not ip:
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
# RFC 6598 shared address space (CGNAT) will be treated separately; don't mark as private here
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved or addr.is_multicast:
|
||||
return True
|
||||
# 0.0.0.0 and unspecified
|
||||
if addr.is_unspecified:
|
||||
return True
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_cgnat_ip(ip: str) -> bool:
|
||||
"""Return True if IP is in carrier-grade NAT (100.64.0.0/10)."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return addr in ipaddress.ip_network("100.64.0.0/10")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _get_external_ip_via_socket() -> Optional[str]:
|
||||
"""
|
||||
Determine which local IP the OS would use to route to external servers.
|
||||
|
||||
Opens a UDP socket to well-known DNS servers (Google, Cloudflare, OpenDNS)
|
||||
without sending data, then reads the local interface IP. This typically
|
||||
returns a private/NAT IP (e.g. 192.168.x.x) which is filtered out. It
|
||||
only returns a result when the host has a directly-assigned public IP.
|
||||
|
||||
Returns:
|
||||
A public IP if the local interface has one, None otherwise.
|
||||
"""
|
||||
# Try multiple well-known servers to increase reliability
|
||||
servers = [
|
||||
("8.8.8.8", 80), # Google DNS
|
||||
("1.1.1.1", 80), # Cloudflare DNS
|
||||
("208.67.222.222", 80), # OpenDNS
|
||||
]
|
||||
|
||||
for server_ip, port in servers:
|
||||
try:
|
||||
# Using context manager ensures socket is always closed
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
# Connect to determine which local interface would be used
|
||||
s.connect((server_ip, port))
|
||||
detected_ip = s.getsockname()[0]
|
||||
|
||||
# Return the first non-private IP we find
|
||||
if detected_ip and not _is_private_ip(detected_ip):
|
||||
return detected_ip
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_external_ip_automatically() -> Optional[str]:
|
||||
"""
|
||||
Attempt to automatically determine the external IP address using
|
||||
privacy-friendly methods in order of preference:
|
||||
|
||||
1. UPnP (query local router) - most privacy-friendly
|
||||
2. Socket connection (determine external interface) - minimal external contact
|
||||
|
||||
Returns:
|
||||
External IP address if successful, None otherwise.
|
||||
"""
|
||||
# Try UPnP first (most privacy-friendly)
|
||||
ip = _get_external_ip_via_upnp()
|
||||
if ip:
|
||||
return ip
|
||||
|
||||
# Fallback to socket method
|
||||
ip = _get_external_ip_via_socket()
|
||||
if ip:
|
||||
return ip
|
||||
|
||||
# Final fallback: single DNS query to OpenDNS (privacy-light)
|
||||
ip = _resolve_public_ip_via_opendns()
|
||||
if ip and not _is_private_ip(ip):
|
||||
debug_log(f"Public IP resolved via OpenDNS: {ip}", "location")
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_database_path() -> Path:
|
||||
"""Get the path where the GeoLite2 database should be stored."""
|
||||
base_dir = Path.home() / ".local" / "share" / "jarvis" / "geoip"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
return base_dir / "GeoLite2-City.mmdb"
|
||||
|
||||
|
||||
def _print_location_setup_instructions(db_path: Path) -> None:
|
||||
"""Print user-friendly location setup instructions with proper formatting."""
|
||||
global _location_warning_shown
|
||||
|
||||
# Only show warning once per session
|
||||
if _location_warning_shown:
|
||||
return
|
||||
|
||||
_location_warning_shown = True
|
||||
|
||||
print(" 📍 Location features are not available")
|
||||
print()
|
||||
print(" To enable location-based features:")
|
||||
print(" 1. 🌐 Register for a free MaxMind account:")
|
||||
print(" https://www.maxmind.com/en/geolite2/signup")
|
||||
print()
|
||||
print(" 2. 📥 Download the GeoLite2 City database (MMDB format)")
|
||||
print()
|
||||
print(" 3. 📂 Save the database file as:")
|
||||
print(f" {db_path}")
|
||||
|
||||
|
||||
def _download_geolite2_database() -> bool:
|
||||
"""
|
||||
Download the GeoLite2 City database from MaxMind.
|
||||
Note: This requires registration for a license key since 2019.
|
||||
For now, we'll provide instructions for manual download.
|
||||
"""
|
||||
try:
|
||||
db_path = _get_database_path()
|
||||
|
||||
# Check if database already exists and is recent (less than 30 days old)
|
||||
if db_path.exists():
|
||||
age_days = (datetime.now(timezone.utc) - datetime.fromtimestamp(db_path.stat().st_mtime, tz=timezone.utc)).days
|
||||
if age_days < 30:
|
||||
debug_log("GeoLite2 database found and up to date", "location")
|
||||
return True
|
||||
|
||||
debug_log(f"GeoLite2 database not found or outdated at: {db_path}", "location")
|
||||
_print_location_setup_instructions(db_path)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"Error checking database: {e}", "location")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_public_ip_via_opendns(timeout: float = 1.5) -> Optional[str]:
|
||||
"""Resolve true public IP via a single DNS query to OpenDNS (myip.opendns.com)."""
|
||||
try:
|
||||
resolver_ip = ("208.67.222.222", 53)
|
||||
tid = random.randint(0, 0xFFFF)
|
||||
header = tid.to_bytes(2, 'big') + b"\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"
|
||||
labels = b"".join(len(part).to_bytes(1, 'big') + part.encode('ascii') for part in "myip.opendns.com".split('.')) + b"\x00"
|
||||
qtype_qclass = b"\x00\x01\x00\x01"
|
||||
packet = header + labels + qtype_qclass
|
||||
|
||||
# Using context manager ensures socket is always closed
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(timeout)
|
||||
s.sendto(packet, resolver_ip)
|
||||
data, _ = s.recvfrom(512)
|
||||
|
||||
if len(data) < 12 or data[0:2] != tid.to_bytes(2, 'big'):
|
||||
return None
|
||||
question_len = len(labels) + 4
|
||||
answer_start = 12 + question_len
|
||||
if len(data) < answer_start + 12:
|
||||
return None
|
||||
# Validate answer is an A record (RTYPE=1, RCLASS=1)
|
||||
rtype = int.from_bytes(data[answer_start + 2:answer_start + 4], 'big') if len(data) >= answer_start + 4 else 0
|
||||
rclass = int.from_bytes(data[answer_start + 4:answer_start + 6], 'big') if len(data) >= answer_start + 6 else 0
|
||||
if rtype != 1 or rclass != 1:
|
||||
return None
|
||||
rdlength = int.from_bytes(data[answer_start + 10:answer_start + 12], 'big') if len(data) >= answer_start + 12 else 0
|
||||
rdata_start = answer_start + 12
|
||||
rdata_end = rdata_start + rdlength
|
||||
if rdlength == 4 and len(data) >= rdata_end:
|
||||
ip_bytes = data[rdata_start:rdata_end]
|
||||
return '.'.join(str(b) for b in ip_bytes)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_location_info(
|
||||
ip_address: Optional[str] = None,
|
||||
*,
|
||||
config_ip: Optional[str] = None,
|
||||
auto_detect: bool = True,
|
||||
resolve_cgnat_public_ip: bool = True,
|
||||
location_cache_minutes: int = 60,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get location information for an IP address.
|
||||
|
||||
Args:
|
||||
ip_address: Direct IP address to look up. If provided it is used as-is.
|
||||
config_ip: Manually configured public IP (takes precedence over auto-detect when ip_address is None).
|
||||
auto_detect: Attempt to discover an external IP via UPnP / socket heuristics if neither ip_address nor config_ip given.
|
||||
resolve_cgnat_public_ip: If True and a CGNAT (100.64.0.0/10) address is detected, attempt a single DNS query via OpenDNS to discover the true public IP (privacy-light).
|
||||
location_cache_minutes: TTL in minutes for cached location lookups persisted to disk.
|
||||
"""
|
||||
if not GEOIP2_AVAILABLE:
|
||||
return {"error": "geoip2 library not available"}
|
||||
|
||||
# Get IP address to lookup (prioritize parameter, then config, then auto-detect)
|
||||
if ip_address is None:
|
||||
if config_ip:
|
||||
ip_address = config_ip
|
||||
elif auto_detect:
|
||||
# Try automatic detection using privacy-friendly methods
|
||||
ip_address = _get_external_ip_automatically()
|
||||
if not ip_address:
|
||||
# Final fallback to local IP (won't work for geolocation)
|
||||
ip_address = _get_local_network_ip()
|
||||
else:
|
||||
# Fall back to local IP without auto-detection
|
||||
ip_address = _get_local_network_ip()
|
||||
|
||||
if not ip_address:
|
||||
return {"error": "No IP address available. Try enabling auto-detection or configure 'location_ip_address' in your config."}
|
||||
|
||||
# Mark CGNAT and optionally resolve public IP via OpenDNS if enabled in settings
|
||||
cgnat_flag = _is_cgnat_ip(ip_address)
|
||||
if cgnat_flag and resolve_cgnat_public_ip:
|
||||
now = datetime.now(timezone.utc)
|
||||
needs_resolve = False
|
||||
with _cache_lock:
|
||||
cache_entry = _cgnat_resolution_cache.get(ip_address)
|
||||
if cache_entry:
|
||||
ts, cached_public = cache_entry
|
||||
if now - ts < _CGNAT_RESOLUTION_TTL:
|
||||
# Use cached result (even if None to avoid extra queries)
|
||||
if cached_public and not _is_cgnat_ip(cached_public) and not _is_private_ip(cached_public):
|
||||
debug_log(f"CGNAT IP {ip_address} used cached public {cached_public}", "location")
|
||||
ip_address = cached_public
|
||||
else:
|
||||
# Expired entry
|
||||
_cgnat_resolution_cache.pop(ip_address, None)
|
||||
needs_resolve = True
|
||||
else:
|
||||
needs_resolve = True
|
||||
if needs_resolve:
|
||||
resolved = _resolve_public_ip_via_opendns()
|
||||
with _cache_lock:
|
||||
_cgnat_resolution_cache[ip_address] = (now, resolved)
|
||||
# Persist CGNAT cache change
|
||||
_persist_disk_caches(location_cache_minutes)
|
||||
if resolved and not _is_cgnat_ip(resolved) and not _is_private_ip(resolved):
|
||||
debug_log(f"CGNAT IP {ip_address} resolved to public {resolved} via OpenDNS", "location")
|
||||
ip_address = resolved
|
||||
|
||||
# Return cached location result if we already computed for this final ip_address
|
||||
if ip_address in _location_cache:
|
||||
cached = _location_cache[ip_address]
|
||||
# Negative results (errors) expire after location_cache_minutes so DB updates can take effect
|
||||
if "error" in cached:
|
||||
cached_at = cached.get("_cached_at")
|
||||
if cached_at and datetime.now(timezone.utc) - cached_at > timedelta(minutes=location_cache_minutes):
|
||||
with _cache_lock:
|
||||
_location_cache.pop(ip_address, None)
|
||||
else:
|
||||
# Ensure we always include ip key even if older cache missing it
|
||||
if 'ip' not in cached:
|
||||
cached['ip'] = ip_address
|
||||
result = cached.copy()
|
||||
result.pop("_cached_at", None)
|
||||
return result
|
||||
else:
|
||||
# Ensure we always include ip key even if older cache missing it
|
||||
if 'ip' not in cached:
|
||||
cached['ip'] = ip_address
|
||||
return cached.copy()
|
||||
|
||||
# Check if database is available
|
||||
db_path = _get_database_path()
|
||||
if not db_path.exists():
|
||||
if not _download_geolite2_database():
|
||||
return {"error": "GeoLite2 database not available"}
|
||||
|
||||
try:
|
||||
with geoip2.database.Reader(str(db_path)) as reader:
|
||||
response = reader.city(ip_address)
|
||||
|
||||
location_info = {
|
||||
"ip": ip_address,
|
||||
"country": response.country.name,
|
||||
"country_code": response.country.iso_code,
|
||||
"region": response.subdivisions.most_specific.name,
|
||||
"region_code": response.subdivisions.most_specific.iso_code,
|
||||
"city": response.city.name,
|
||||
"latitude": float(response.location.latitude) if response.location.latitude else None,
|
||||
"longitude": float(response.location.longitude) if response.location.longitude else None,
|
||||
"timezone": response.location.time_zone,
|
||||
"accuracy_radius": response.location.accuracy_radius,
|
||||
}
|
||||
|
||||
# Clean up None values and empty strings
|
||||
cleaned_info = {k: v for k, v in location_info.items() if v is not None and v != ""}
|
||||
debug_log(f"Location detected: {cleaned_info.get('city', 'Unknown city')}, {cleaned_info.get('country', 'Unknown country')}", "location")
|
||||
# Cache successful lookup
|
||||
_location_cache[ip_address] = cleaned_info.copy()
|
||||
_persist_disk_caches(location_cache_minutes)
|
||||
return cleaned_info
|
||||
|
||||
except geoip2.errors.AddressNotFoundError:
|
||||
debug_log(f"IP address {ip_address} not found in database", "location")
|
||||
reason = "cgnat_not_found" if cgnat_flag else "not_found"
|
||||
advice = (
|
||||
"Detected CGNAT (100.64.0.0/10). Configure 'location_ip_address' with a real public IP or disable auto-detect."
|
||||
if cgnat_flag else
|
||||
"If this is CGNAT or a very new allocation, configure 'location_ip_address' manually."
|
||||
)
|
||||
result = {
|
||||
"error": f"IP address {ip_address} not found in database",
|
||||
"ip": ip_address,
|
||||
"reason": reason,
|
||||
"advice": advice,
|
||||
}
|
||||
# Cache negative result with TTL so it expires and retries after DB updates
|
||||
cached_result = result.copy()
|
||||
cached_result["_cached_at"] = datetime.now(timezone.utc)
|
||||
_location_cache[ip_address] = cached_result
|
||||
_persist_disk_caches(location_cache_minutes)
|
||||
return result
|
||||
except Exception as e:
|
||||
debug_log(f"Error looking up location: {e}", "location")
|
||||
result = {"error": f"Error looking up location: {e}", "ip": ip_address}
|
||||
cached_result = result.copy()
|
||||
cached_result["_cached_at"] = datetime.now(timezone.utc)
|
||||
_location_cache[ip_address] = cached_result
|
||||
_persist_disk_caches(location_cache_minutes)
|
||||
return result
|
||||
|
||||
|
||||
def _format_location_context(location_info: Dict[str, Any]) -> str:
|
||||
"""Format a location_info dict into a one-line human-readable context string."""
|
||||
if "error" in location_info:
|
||||
return "Location: Unknown"
|
||||
|
||||
parts = []
|
||||
|
||||
if location_info.get("city"):
|
||||
if location_info.get("region"):
|
||||
parts.append(f"{location_info['city']}, {location_info['region']}")
|
||||
else:
|
||||
parts.append(location_info["city"])
|
||||
elif location_info.get("region"):
|
||||
parts.append(location_info["region"])
|
||||
|
||||
if location_info.get("country"):
|
||||
parts.append(location_info["country"])
|
||||
|
||||
if location_info.get("timezone"):
|
||||
parts.append(f"({location_info['timezone']})")
|
||||
|
||||
if parts:
|
||||
return f"Location: {', '.join(parts)}"
|
||||
return "Location: Unknown"
|
||||
|
||||
|
||||
def get_location_context(
|
||||
*,
|
||||
config_ip: Optional[str] = None,
|
||||
auto_detect: bool = True,
|
||||
resolve_cgnat_public_ip: bool = True,
|
||||
location_cache_minutes: int = 60,
|
||||
) -> str:
|
||||
"""Generate a concise location context string using explicit parameters."""
|
||||
return _format_location_context(get_location_info(
|
||||
config_ip=config_ip,
|
||||
auto_detect=auto_detect,
|
||||
resolve_cgnat_public_ip=resolve_cgnat_public_ip,
|
||||
location_cache_minutes=location_cache_minutes,
|
||||
))
|
||||
|
||||
|
||||
def get_location_context_with_timezone(
|
||||
*,
|
||||
config_ip: Optional[str] = None,
|
||||
auto_detect: bool = True,
|
||||
resolve_cgnat_public_ip: bool = True,
|
||||
location_cache_minutes: int = 60,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""Return the location context string and the IANA timezone (if known) in one lookup."""
|
||||
info = get_location_info(
|
||||
config_ip=config_ip,
|
||||
auto_detect=auto_detect,
|
||||
resolve_cgnat_public_ip=resolve_cgnat_public_ip,
|
||||
location_cache_minutes=location_cache_minutes,
|
||||
)
|
||||
tz_name = info.get("timezone") if isinstance(info, dict) else None
|
||||
return _format_location_context(info), tz_name
|
||||
|
||||
|
||||
def is_location_available() -> bool:
|
||||
"""Check if location detection is available and working."""
|
||||
if not GEOIP2_AVAILABLE:
|
||||
return False
|
||||
|
||||
db_path = _get_database_path()
|
||||
return db_path.exists()
|
||||
|
||||
|
||||
def setup_location_database() -> bool:
|
||||
"""
|
||||
Setup the location database. This will check for the database
|
||||
and provide instructions if it's not available.
|
||||
|
||||
Returns:
|
||||
True if database is available and ready, False otherwise.
|
||||
"""
|
||||
if not GEOIP2_AVAILABLE:
|
||||
print("📦 Location library not installed")
|
||||
print()
|
||||
print(" To install the required geoip2 library:")
|
||||
print(" pip install geoip2")
|
||||
print()
|
||||
debug_log("geoip2 library not available", "location")
|
||||
return False
|
||||
|
||||
return _download_geolite2_database()
|
||||
|
||||
|
||||
def get_detailed_location_info(
|
||||
ip_address: Optional[str] = None,
|
||||
*,
|
||||
config_ip: Optional[str] = None,
|
||||
auto_detect: bool = True,
|
||||
resolve_cgnat_public_ip: bool = True,
|
||||
location_cache_minutes: int = 60,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get detailed location information including coordinates and formatted address."""
|
||||
location_info = get_location_info(
|
||||
ip_address,
|
||||
config_ip=config_ip,
|
||||
auto_detect=auto_detect,
|
||||
resolve_cgnat_public_ip=resolve_cgnat_public_ip,
|
||||
location_cache_minutes=location_cache_minutes,
|
||||
)
|
||||
|
||||
if "error" in location_info:
|
||||
return location_info
|
||||
|
||||
# Add computed fields
|
||||
if location_info.get("latitude") and location_info.get("longitude"):
|
||||
location_info["coordinates"] = f"{location_info['latitude']}, {location_info['longitude']}"
|
||||
|
||||
# Add formatted address
|
||||
address_parts = []
|
||||
for field in ["city", "region", "country"]:
|
||||
if location_info.get(field):
|
||||
address_parts.append(location_info[field])
|
||||
|
||||
if address_parts:
|
||||
location_info["formatted_address"] = ", ".join(address_parts)
|
||||
|
||||
return location_info
|
||||
|
||||
|
||||
# For testing and debugging
|
||||
if __name__ == "__main__":
|
||||
print("Testing location detection...")
|
||||
|
||||
# Test local IP detection (privacy-focused)
|
||||
ip = _get_local_network_ip()
|
||||
print(f"Local IP: {ip}")
|
||||
|
||||
# Test location lookup (will likely fail without public IP)
|
||||
location = get_location_info()
|
||||
print(f"Location info: {location}")
|
||||
|
||||
# Test context generation
|
||||
context = get_location_context()
|
||||
print(f"Location context: {context}")
|
||||
|
||||
# Test detailed info
|
||||
detailed = get_detailed_location_info()
|
||||
print(f"Detailed location: {detailed}")
|
||||
|
||||
print("\nNote: For accurate location detection, configure 'location_ip_address' in your config")
|
||||
print("or provide an IP address explicitly to respect privacy.")
|
||||
Reference in New Issue
Block a user