feat: Refactor geocoding and country/state handling in commands

- Updated utility functions to improve country name validation, ensuring accurate identification of country names and US states.
- Enhanced geocoding logic in multiple command classes to handle default state and country configurations more effectively.
- Modified error messages to reflect the correct region based on user input, improving user experience.
- Added short usage descriptions for satellite pass commands in multiple languages, enhancing internationalization support.
This commit is contained in:
agessaman
2026-01-19 22:01:47 -08:00
parent c53bfce4b7
commit 04478acf51
15 changed files with 110 additions and 126 deletions
@@ -62,7 +62,7 @@ class GlobalWxCommand(BaseCommand):
self.wxsim_parser = None
# Get default state and country from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='WA')
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
# Get unit preferences from config
+9 -6
View File
@@ -48,8 +48,9 @@ class AqiCommand(BaseCommand):
self.aqi_enabled = self.get_config_value('Aqi_Command', 'enabled', fallback=True, value_type='bool')
self.url_timeout = 10 # seconds
# Get default state from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='WA')
# Get default state and country from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
# Get timezone from config
self.timezone = self.bot.config.get('Bot', 'timezone', fallback='America/Los_Angeles')
@@ -95,7 +96,8 @@ class AqiCommand(BaseCommand):
}
def get_help_text(self) -> str:
return f"Usage: aqi <city|neighborhood|city country|lat,lon|help> - Get AQI for city/neighborhood in {self.default_state}, international cities, coordinates, or pollutant help"
region = self.default_state or self.default_country
return f"Usage: aqi <city|neighborhood|city country|lat,lon|help> - Get AQI for city/neighborhood in {region}, international cities, coordinates, or pollutant help"
def can_execute(self, message: MeshMessage) -> bool:
"""Check if this command can be executed with the given message.
@@ -475,7 +477,8 @@ class AqiCommand(BaseCommand):
if ',' in location and any(country in location.lower() for country in ['canada', 'mexico', 'uk', 'france', 'germany', 'italy', 'spain', 'australia', 'japan', 'china', 'india', 'brazil', 'uae', 'russia', 'korea', 'thailand', 'singapore', 'egypt', 'turkey']):
return f"Could not find city '{location}'"
else:
return f"Could not find city '{location}' in {self.default_state}"
region = self.default_state or self.default_country
return f"Could not find city '{location}' in {region}"
# Check if the found city is in a different state than default
actual_city = location
@@ -528,7 +531,7 @@ class AqiCommand(BaseCommand):
if location_type == "city" and address_info:
# Always try to include city name if there's space
# Use abbreviate_location to shorten long location strings (e.g., "United States of America" -> "USA")
full_location = f"{actual_city}, {actual_state}"
full_location = f"{actual_city}, {actual_state}" if actual_state else actual_city
city_display = abbreviate_location(full_location, max_length=30)
# Check if we have space for the city name
@@ -588,7 +591,7 @@ class AqiCommand(BaseCommand):
actual_state = "USA"
# Use abbreviate_location to shorten long location strings (e.g., "United States of America" -> "USA")
full_location = f"{actual_city}, {actual_state}"
full_location = f"{actual_city}, {actual_state}" if actual_state else actual_city
city_display = abbreviate_location(full_location, max_length=30)
# Check if we have space for the city name
+2 -41
View File
@@ -72,9 +72,8 @@ class SatpassCommand(BaseCommand):
# Check if user provided a satellite number
content = message.content.strip()
if content == 'satpass':
# No satellite specified, show help
help_text = self._get_help_text()
await self.send_response(message, help_text)
# No satellite specified, show short usage (fits message length limit)
await self.send_response(message, self.translate('commands.satpass.usage_short'))
return True
# Extract satellite identifier from command
@@ -113,44 +112,6 @@ class SatpassCommand(BaseCommand):
await self.send_response(message, error_msg)
return False
def _get_help_text(self) -> str:
"""Get detailed help text with shortcuts.
Returns:
str: Detailed help text including shortcuts.
"""
shortcuts_text = self.translate('commands.satpass.help_header')
# Group shortcuts by category for better organization
weather_sats = ['noaa15', 'noaa18', 'noaa19', 'metop-a', 'metop-b', 'metop-c', 'goes16', 'goes17', 'goes18']
space_stations = ['iss', 'tiangong', 'tiangong1', 'tiangong2']
telescopes = ['hst', 'hubble']
other = ['starlink']
# Add weather satellites
shortcuts_text += self.translate('commands.satpass.category_weather')
weather_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in weather_sats if name in self.SATELLITE_SHORTCUTS]
shortcuts_text += ", ".join(weather_list) + "\n"
# Add space stations
shortcuts_text += self.translate('commands.satpass.category_stations')
station_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in space_stations if name in self.SATELLITE_SHORTCUTS]
shortcuts_text += ", ".join(station_list) + "\n"
# Add telescopes
shortcuts_text += self.translate('commands.satpass.category_telescopes')
telescope_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in telescopes if name in self.SATELLITE_SHORTCUTS]
shortcuts_text += ", ".join(telescope_list) + "\n"
# Add other satellites
shortcuts_text += self.translate('commands.satpass.category_other')
other_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in other if name in self.SATELLITE_SHORTCUTS]
shortcuts_text += ", ".join(other_list) + "\n"
shortcuts_text += self.translate('commands.satpass.examples')
return shortcuts_text
def get_help_text(self) -> str:
"""Get help text for this command.
+1 -1
View File
@@ -59,7 +59,7 @@ class SolarforecastCommand(BaseCommand):
self.forecast_cache = {}
# Get default state from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='WA')
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
# Initialize geocoder (will use rate-limited helpers for actual calls)
self.geolocator = get_nominatim_geocoder()
+9 -6
View File
@@ -90,8 +90,9 @@ class WxCommand(BaseCommand):
self.use_metric = False # Use imperial units by default
self.zulu_time = False # Use local time by default
# Get default state from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='WA')
# Get default state and country from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
# Initialize geocoder (will use rate-limited helpers for actual calls)
# Keep geolocator for backwards compatibility, but prefer rate-limited helpers
@@ -572,7 +573,8 @@ class WxCommand(BaseCommand):
else:
lat, lon = result
if lat is None or lon is None:
await self.send_response(message, self.translate('commands.wx.no_location_city', location=location, state=self.default_state))
region = self.default_state or self.default_country
await self.send_response(message, self.translate('commands.wx.no_location_city', location=location, state=region))
return True
# Get and display full alert list
@@ -667,11 +669,12 @@ class WxCommand(BaseCommand):
address_info = None
if lat is None or lon is None:
return self.translate('commands.wx.no_location_city', location=location, state=self.default_state)
region = self.default_state or self.default_country
return self.translate('commands.wx.no_location_city', location=location, state=region)
# Check if the found city is in a different state than default
actual_city = location
actual_state = self.default_state
actual_state = self.default_state or self.default_country
if address_info:
# Try to get the best city name from various address fields
actual_city = (address_info.get('city') or
@@ -715,7 +718,7 @@ class WxCommand(BaseCommand):
actual_state != default_state_full)
# Always show location if using companion location, or if state is different
if using_companion_location or states_different:
location_prefix = f"{actual_city}, {actual_state}: "
location_prefix = f"{actual_city}, {actual_state}: " if actual_state else f"{actual_city}: "
elif location_type == "zipcode" and using_companion_location:
# For zipcode with companion location, try to get city name from reverse geocoding
location_str = self._coordinates_to_location_string(lat, lon)
+79 -71
View File
@@ -499,21 +499,23 @@ def is_country_name(text: str) -> bool:
Returns:
bool: True if text appears to be a country name
"""
if not text or len(text) <= 2:
if not text:
return False
if PYCOUNTRY_AVAILABLE:
iso_code, _ = normalize_country_name(text)
return iso_code is not None
if iso_code is not None:
return True
# Fallback: if it's longer than 2 chars and not a common US state, assume country
if US_AVAILABLE:
state_abbr, _ = normalize_us_state(text)
if state_abbr:
return False # It's a US state, not a country
# If longer than 2 chars and not a US state, likely a country
return len(text) > 2
if len(text) <= 2:
return False # Unknown 2-char (not a known country or US state)
return len(text) > 2 # Longer text, assume country
def is_us_state(text: str) -> bool:
@@ -801,7 +803,7 @@ async def geocode_city(bot: Any, city: str, default_state: str = None,
try:
# Get defaults from config if not provided
if default_state is None:
default_state = bot.config.get('Weather', 'default_state', fallback='WA')
default_state = bot.config.get('Weather', 'default_state', fallback='')
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
@@ -845,9 +847,10 @@ async def geocode_city(bot: Any, city: str, default_state: str = None,
else:
country_name = second_part
# Handle major cities with multiple locations (prioritize major cities)
# Handle major cities with multiple locations (prioritize major cities).
# Skip when user specified a country (e.g. "Paris, FR") so we honor their choice.
major_city_queries = get_major_city_queries(city_clean, state_abbr)
if major_city_queries:
if major_city_queries and not country_name:
# Try major city options first
for major_city_query in major_city_queries:
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(major_city_query)
@@ -1017,38 +1020,40 @@ async def geocode_city(bot: Any, city: str, default_state: str = None,
address_info = {}
return lat, lon, address_info
# Try with default state (fallback for US cities when no country specified)
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = await rate_limited_nominatim_geocode(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
# Try with default state (fallback for US cities when no country specified).
# Skip when default_state is empty (e.g. non-US default_country or key unset).
if default_state and default_state.strip():
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
location = await rate_limited_nominatim_geocode(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try without state
location = await rate_limited_nominatim_geocode(bot, f"{city_clean}, {default_country}", timeout=timeout)
if location:
@@ -1102,7 +1107,7 @@ def geocode_city_sync(bot: Any, city: str, default_state: str = None,
try:
# Get defaults from config if not provided
if default_state is None:
default_state = bot.config.get('Weather', 'default_state', fallback='WA')
default_state = bot.config.get('Weather', 'default_state', fallback='')
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
@@ -1147,9 +1152,10 @@ def geocode_city_sync(bot: Any, city: str, default_state: str = None,
else:
country_name = second_part
# Handle major cities with multiple locations (prioritize major cities)
# Handle major cities with multiple locations (prioritize major cities).
# Skip when user specified a country (e.g. "Paris, FR") so we honor their choice.
major_city_queries = get_major_city_queries(city_clean, state_abbr)
if major_city_queries:
if major_city_queries and not country_name:
# Try major city options first
for major_city_query in major_city_queries:
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(major_city_query)
@@ -1314,38 +1320,40 @@ def geocode_city_sync(bot: Any, city: str, default_state: str = None,
address_info = {}
return lat, lon, address_info
# Try with default state (fallback for US cities when no country specified)
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = rate_limited_nominatim_geocode_sync(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
# Try with default state (fallback for US cities when no country specified).
# Skip when default_state is empty (e.g. non-US default_country or key unset).
if default_state and default_state.strip():
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
location = rate_limited_nominatim_geocode_sync(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try without state
location = rate_limited_nominatim_geocode_sync(bot, f"{city_clean}, {default_country}", timeout=timeout)
if location:
+1
View File
@@ -514,6 +514,7 @@
"header": "🛰️ Satellitenvorbeiflug:\n{pass_info}",
"error": "Fehler beim Abruf der Satellitenvorbeiflug-Info: {error}",
"no_satellite": "Bitte NORAD-Nummer oder Kürzel angeben. Beispiel: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visuell]. Z.B.: satpass iss, satpass 25544. help satpass für Kürzel.",
"help_header": "🛰️ Satellitenvorbeiflug-Info\n\nVerwendung: satpass <NORAD_Nummer_oder_Kürzel>\n\nKürzel:\n",
"category_weather": "🌤️ Wetter: ",
"category_stations": "🚀 Stationen: ",
+1
View File
@@ -494,6 +494,7 @@
"header": "🛰️ Satellite Pass:\n{pass_info}",
"error": "Error getting satellite pass info: {error}",
"no_satellite": "Please provide a satellite NORAD number or shortcut. Example: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visual]. Ex: satpass iss, satpass 25544. help satpass for shortcuts.",
"help_header": "🛰️ Satellite Pass Info\n\nUsage: satpass <NORAD_number_or_shortcut>\n\nShortcuts:\n",
"category_weather": "🌤️ Weather: ",
"category_stations": "🚀 Stations: ",
+1
View File
@@ -437,6 +437,7 @@
"header": "🛰️ Satellite Pass:\n{pass_info}",
"error": "Error getting satellite pass info: {error}",
"no_satellite": "Please provide a satellite NORAD number or shortcut. Example: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visual]. Ex: satpass iss, satpass 25544. help satpass for shortcuts.",
"help_header": "🛰️ Satellite Pass Info\n\nUsage: satpass <NORAD_number_or_shortcut>\n\nShortcuts:\n",
"category_weather": "🌤️ Weather: ",
"category_stations": "🚀 Stations: ",
+1
View File
@@ -370,6 +370,7 @@
"header": "🛰️ Paso de Satélite:\n{pass_info}",
"error": "Error al obtener información de paso de satélite: {error}",
"no_satellite": "Por favor proporciona un número NORAD de satélite o atajo. Ejemplo: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visual]. Ej: satpass iss, satpass 25544. help satpass para atajos.",
"help_header": "🛰️ Info de Paso de Satélite\n\nUso: satpass <número_NORAD_o_atajo>\n\nAtajos:\n",
"category_weather": "🌤️ Clima: ",
"category_stations": "🚀 Estaciones: ",
+1
View File
@@ -566,6 +566,7 @@
"header": "🛰️ Passage de satellite :\n{pass_info}",
"error": "Erreur lors de l'obtention des infos de passage : {error}",
"no_satellite": "Veuillez fournir un numéro NORAD ou un raccourci de satellite. Exemple : satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visuel]. Ex : satpass iss, satpass 25544. help satpass pour les raccourcis.",
"help_header": "🛰️ Infos de passage de satellite\n\nUtilisation : satpass <numéro_NORAD_ou_raccourci>\n\nRaccourcis :\n",
"category_weather": "🌤️ Météo : ",
"category_stations": "🚀 Stations : ",
+1
View File
@@ -494,6 +494,7 @@
"header": "🛰️ Passage satellite:\n{pass_info}",
"error": "Erreur infos passage satellite: {error}",
"no_satellite": "Fournissez numéro NORAD satellite ou raccourci. Ex: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visuel]. Ex: satpass iss, satpass 25544. help satpass pour raccourcis.",
"help_header": "🛰️ Infos passage satellite\n\nUsage: satpass <numéro_NORAD_ou_raccourci>\n\nRaccourcis:\n",
"category_weather": "🌤️ Météo: ",
"category_stations": "🚀 Stations: ",
+1
View File
@@ -494,6 +494,7 @@
"header": "🛰️ Satellietpassage:\n{pass_info}",
"error": "Fout bij ophalen satellietpassage-info: {error}",
"no_satellite": "Geef een NORAD-nummer of snelkoppeling op. Voorbeeld: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visueel]. Bijv: satpass iss, satpass 25544. help satpass voor snelkoppelingen.",
"help_header": "🛰️ Satellietpassage info\n\nGebruik: satpass <NORAD_nummer_of_snelkoppeling>\n\nSnelkoppelingen:\n",
"category_weather": "🌤️ Weer: ",
"category_stations": "🚀 Stations: ",
+1
View File
@@ -491,6 +491,7 @@
"header": "🛰️ Passagem de Satélite:\n{pass_info}",
"error": "Erro ao pegar info de passagem: {error}",
"no_satellite": "Manda o número NORAD ou atalho do satélite. Ex: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visual]. Ex: satpass iss, satpass 25544. help satpass pra atalhos.",
"help_header": "🛰️ Info Passagem Satélite\n\nUso: satpass <número_NORAD_ou_atalho>\n\nAtalhos:\n",
"category_weather": "🌤️ Meteorológicos: ",
"category_stations": "🚀 Estações: ",
+1
View File
@@ -490,6 +490,7 @@
"header": "🛰️ Passagem de Satélite:\n{pass_info}",
"error": "Erro ao obter info de passagem: {error}",
"no_satellite": "Por favor forneça número NORAD ou atalho do satélite. Exemplo: satpass iss",
"usage_short": "🛰️ satpass <sat|NORAD> [visual]. Ex: satpass iss, satpass 25544. help satpass para atalhos.",
"help_header": "🛰️ Info Passagem Satélite\n\nUso: satpass <número_NORAD_ou_atalho>\n\nAtalhos:\n",
"category_weather": "🌤️ Meteorológicos: ",
"category_stations": "🚀 Estações: ",