feat(weather): add default city configuration for wx commands

- Introduced a new configuration option `default_city` in `config.ini.example` for fallback city names when no location is provided.
- Updated `wx_command.py` and `wx_international.py` to utilize the `default_city` for location disambiguation, enhancing user experience when companion location is unavailable.
- Improved logging to reflect the use of default city or bot location as fallback options.
This commit is contained in:
agessaman
2026-04-25 21:15:51 -07:00
parent 0a758147e1
commit d34ad24e9b
4 changed files with 249 additions and 55 deletions
+4
View File
@@ -656,6 +656,10 @@ weather_provider = noaa
# Set to an empty value (weather_model =) to omit model selection and let Open-Meteo auto-select
#weather_model =
# Default city name for empty "wx" / "gwx" commands when companion location is unavailable
# Use English city names (examples: Seattle or Seattle, WA)
#default_city =
# Default state for city name disambiguation
# When users type "wx seattle", it will search for "seattle, WA, USA"
# Use 2-letter state abbreviation (e.g., WA, CA, NY, TX)
@@ -80,7 +80,8 @@ class GlobalWxCommand(BaseCommand):
self.weather_model = self._load_weather_model()
# Get default state and country from config for city disambiguation
# Get default location/state/country from config for fallback/disambiguation
self.default_city = self.bot.config.get('Weather', 'default_city', fallback='').strip()
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
@@ -460,36 +461,47 @@ class GlobalWxCommand(BaseCommand):
parts = [parts[0], location_str]
self.logger.info(f"Using companion coordinates: {location_str}")
else:
# No companion location: optionally use bot's configured coordinates
use_bot = self.get_config_value(
'Wx_Command',
'use_bot_location_when_no_location',
fallback=False,
value_type='bool',
)
bot_loc = self._get_bot_location() if use_bot else None
if bot_loc:
location_str = self._coordinates_to_location_string(bot_loc[0], bot_loc[1])
if location_str:
parts = [parts[0], location_str]
self.logger.info(
f"Using bot location (no args): {location_str} "
f"({bot_loc[0]}, {bot_loc[1]})"
)
else:
location_str = f"{bot_loc[0]},{bot_loc[1]}"
parts = [parts[0], location_str]
self.logger.info(f"Using bot coordinates (no args): {location_str}")
# No companion location: use default city if configured, then bot location fallback
if self.default_city:
location_parts = [self.default_city]
if self.default_state:
location_parts.append(self.default_state)
if self.default_country:
location_parts.append(self.default_country)
location_str = ", ".join(location_parts)
parts = [parts[0], location_str]
self.logger.info(f"Using default city (no args): {location_str}")
else:
if use_bot:
self.logger.debug(
"use_bot_location_when_no_location enabled but bot_latitude/bot_longitude "
"not set; showing usage"
)
# No default city: optionally use bot's configured coordinates
use_bot = self.get_config_value(
'Wx_Command',
'use_bot_location_when_no_location',
fallback=False,
value_type='bool',
)
bot_loc = self._get_bot_location() if use_bot else None
if bot_loc:
location_str = self._coordinates_to_location_string(bot_loc[0], bot_loc[1])
if location_str:
parts = [parts[0], location_str]
self.logger.info(
f"Using bot location (no args): {location_str} "
f"({bot_loc[0]}, {bot_loc[1]})"
)
else:
location_str = f"{bot_loc[0]},{bot_loc[1]}"
parts = [parts[0], location_str]
self.logger.info(f"Using bot coordinates (no args): {location_str}")
else:
self.logger.debug("No companion location found, showing usage")
await self.send_response(message, self.translate('commands.gwx.usage'))
return True
if use_bot:
self.logger.debug(
"use_bot_location_when_no_location enabled but bot_latitude/bot_longitude "
"not set; showing usage"
)
else:
self.logger.debug("No companion/default city location found, showing usage")
await self.send_response(message, self.translate('commands.gwx.usage'))
return True
# Check for forecast type options: "tomorrow", Nd (7d, 10d), or plain digit days 2GWX_MULTIDAY_MAX_DAYS
forecast_type = "default"
+38 -26
View File
@@ -105,7 +105,8 @@ class WxCommand(BaseCommand):
self.use_metric = False # Use imperial units by default
self.zulu_time = False # Use local time by default
# Get default state and country from config for city disambiguation
# Get default location/state/country from config for fallback/disambiguation
self.default_city = self.bot.config.get('Weather', 'default_city', fallback='').strip()
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
@@ -527,34 +528,45 @@ class WxCommand(BaseCommand):
else:
self.logger.info(f"Using companion coordinates: {location_str}")
else:
# No companion location: optionally use bot's configured coordinates
use_bot = self.get_config_value(
'Wx_Command',
'use_bot_location_when_no_location',
fallback=False,
value_type='bool',
)
bot_loc = self._get_bot_location() if use_bot else None
if bot_loc:
location_str = f"{bot_loc[0]},{bot_loc[1]}"
# No companion location: use default city if configured, then bot location fallback
if self.default_city:
location_parts = [self.default_city]
if self.default_state:
location_parts.append(self.default_state)
if self.default_country:
location_parts.append(self.default_country)
location_str = ", ".join(location_parts)
parts = [parts[0], location_str]
display_name = self._coordinates_to_location_string(bot_loc[0], bot_loc[1])
if display_name:
self.logger.info(
f"Using bot location (no args): {display_name} ({bot_loc[0]}, {bot_loc[1]})"
)
else:
self.logger.info(f"Using bot coordinates (no args): {location_str}")
self.logger.info(f"Using default city (no args): {location_str}")
else:
if use_bot:
self.logger.debug(
"use_bot_location_when_no_location enabled but bot_latitude/bot_longitude "
"not set; showing usage"
)
# No default city: optionally use bot's configured coordinates
use_bot = self.get_config_value(
'Wx_Command',
'use_bot_location_when_no_location',
fallback=False,
value_type='bool',
)
bot_loc = self._get_bot_location() if use_bot else None
if bot_loc:
location_str = f"{bot_loc[0]},{bot_loc[1]}"
parts = [parts[0], location_str]
display_name = self._coordinates_to_location_string(bot_loc[0], bot_loc[1])
if display_name:
self.logger.info(
f"Using bot location (no args): {display_name} ({bot_loc[0]}, {bot_loc[1]})"
)
else:
self.logger.info(f"Using bot coordinates (no args): {location_str}")
else:
self.logger.debug("No companion location found, showing usage")
await self.send_response(message, self.translate('commands.wx.usage'))
return True
if use_bot:
self.logger.debug(
"use_bot_location_when_no_location enabled but bot_latitude/bot_longitude "
"not set; showing usage"
)
else:
self.logger.debug("No companion/default city location found, showing usage")
await self.send_response(message, self.translate('commands.wx.usage'))
return True
# Check for "alerts" keyword first (special handling)
show_full_alerts = False
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Unit tests for default_city fallback ordering in wx and gwx commands."""
import asyncio
import configparser
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from modules.commands.alternatives.wx_international import GlobalWxCommand
from modules.commands.wx_command import WxCommand
def _build_config(default_city: str = "", use_bot: bool = False) -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.add_section("Weather")
config.set("Weather", "weather_provider", "noaa")
config.set("Weather", "default_city", default_city)
config.set("Weather", "default_state", "WA")
config.set("Weather", "default_country", "US")
config.add_section("Wx_Command")
config.set(
"Wx_Command",
"use_bot_location_when_no_location",
"true" if use_bot else "false",
)
config.add_section("Bot")
config.set("Bot", "bot_tx_rate_limit_seconds", "1.0")
return config
def _build_bot(config: configparser.ConfigParser, mock_logger):
bot = Mock()
bot.logger = mock_logger
bot.config = config
bot.db_manager = Mock()
bot.translator = Mock()
bot.translator.translate = Mock(side_effect=lambda key, **kwargs: key)
bot.command_manager = Mock()
bot.command_manager.monitor_channels = ["general"]
bot.command_manager.send_response = AsyncMock(return_value=True)
return bot
def _mock_message(content: str) -> SimpleNamespace:
return SimpleNamespace(
content=content,
sender_id="user1",
sender_pubkey="pubkey1",
channel="general",
is_dm=False,
)
def _stub_shared_paths(cmd):
cmd._get_custom_mqtt_weather_topic = Mock(return_value=None)
cmd._get_custom_wxsim_source = Mock(return_value=None)
cmd.send_response = AsyncMock(return_value=True)
cmd.record_execution = Mock()
cmd.translate = Mock(side_effect=lambda key, **kwargs: key)
def test_gwx_empty_prefers_companion_over_default_city(mock_logger):
cmd = GlobalWxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=(1, 2))
cmd._coordinates_to_location_string = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("gwx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "1,2"
def test_gwx_empty_uses_default_city_when_no_companion(mock_logger):
cmd = GlobalWxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("gwx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "Seattle, WA, US"
def test_gwx_empty_falls_back_to_bot_location_when_default_city_missing(mock_logger):
cmd = GlobalWxCommand(_build_bot(_build_config(default_city="", use_bot=True), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd._get_bot_location = Mock(return_value=(47, -122))
cmd._coordinates_to_location_string = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("gwx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "47,-122"
def test_gwx_empty_shows_usage_without_any_fallback(mock_logger):
cmd = GlobalWxCommand(_build_bot(_build_config(default_city="", use_bot=False), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("gwx")))
assert cmd.get_weather_for_location.await_count == 0
assert cmd.send_response.await_count == 1
assert cmd.send_response.await_args.args[1] == "commands.gwx.usage"
def test_wx_empty_prefers_companion_over_default_city(mock_logger):
cmd = WxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=(1, 2))
cmd._coordinates_to_location_string = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("wx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "1,2"
assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is True
def test_wx_empty_uses_default_city_when_no_companion(mock_logger):
cmd = WxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("wx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "Seattle, WA, US"
assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is False
def test_wx_empty_falls_back_to_bot_location_when_default_city_missing(mock_logger):
cmd = WxCommand(_build_bot(_build_config(default_city="", use_bot=True), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd._get_bot_location = Mock(return_value=(47, -122))
cmd._coordinates_to_location_string = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("wx")))
assert cmd.get_weather_for_location.await_count == 1
assert cmd.get_weather_for_location.await_args.args[0] == "47,-122"
assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is False
def test_wx_empty_shows_usage_without_any_fallback(mock_logger):
cmd = WxCommand(_build_bot(_build_config(default_city="", use_bot=False), mock_logger))
_stub_shared_paths(cmd)
cmd._get_companion_location = Mock(return_value=None)
cmd.get_weather_for_location = AsyncMock(return_value="ok")
asyncio.run(cmd.execute(_mock_message("wx")))
assert cmd.get_weather_for_location.await_count == 0
assert cmd.send_response.await_count == 1
assert cmd.send_response.await_args.args[1] == "commands.wx.usage"