Files
javis_bot/src/jarvis/utils/location.py
javis-bot c4abf63f38
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
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
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.
2026-06-09 14:51:05 +09:00

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.")