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.
692 lines
26 KiB
Python
692 lines
26 KiB
Python
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.")
|