diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index a6a93d9..03beca4 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -212,6 +212,16 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): return None + async def _fetch_wxsim( + self, source: str, forecast_type: str, num_days: int, message: MeshMessage, location: Optional[str] + ) -> str: + # Blocking HTTP fetch of the WXSIM plaintext file. + if location is None: + return await asyncio.to_thread(self._get_wxsim_weather, source, forecast_type, num_days, message) + return await asyncio.to_thread( + self._get_wxsim_weather, source, forecast_type, num_days, message, location + ) + def _get_wxsim_weather(self, source_url: str, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None, location_name: Optional[str] = None) -> str: @@ -365,75 +375,21 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): if len(parts) < 2: mqtt_topic = self._get_custom_mqtt_weather_topic(None) if mqtt_topic: - try: - self.record_execution(message.sender_id) - weather_data = self._mqtt_weather_line(mqtt_topic, option_type, None) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error reading MQTT weather: {e}") - await self.send_response(message, self.translate("commands.gwx.error", error=str(e))) - return True + return await self._reply_from_mqtt(message, mqtt_topic, option_type, None, split_multiday=False) wxsim_source = self._get_custom_wxsim_source(None) # Check for default if wxsim_source: - # Use custom WXSIM default source - try: - self.record_execution(message.sender_id) - # Blocking HTTP fetch of the WXSIM plaintext file. - weather_data = await asyncio.to_thread( - self._get_wxsim_weather, wxsim_source, option_type, option_days, message - ) - if option_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error fetching WXSIM weather: {e}") - await self.send_response(message, self.translate('commands.gwx.error', error=str(e))) - return True + return await self._reply_from_wxsim(message, wxsim_source, option_type, option_days) - # No custom source, try companion location - companion_location = self._get_companion_location(message) - if companion_location: - # Forecast the sender's own point. Re-geocoding a reverse-geocoded - # place name could move it to that town's center; the reply's - # label comes from one reverse lookup in geocode_location. - location_str = self._coordinates_query(*companion_location) - parts = [parts[0], location_str] - self.logger.info(f"Using companion coordinates: {location_str}") - else: - # 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: - # 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_query(*bot_loc) - parts = [parts[0], location_str] - self.logger.info(f"Using bot coordinates (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" - ) - else: - self.logger.debug("No companion/default city location found, showing usage") - await self.send_response(message, self.translate('commands.gwx.usage')) - return True + # No custom source: the sender's position, default city, then the bot's position. + # Positions are forecast as coordinates: re-geocoding a reverse-geocoded + # place name could move it to that town's center; the reply's label + # comes from one reverse lookup in geocode_location. + location_str, _ = await self._no_location_fallback(message) + if location_str is None: + await self.send_response(message, self.translate('commands.gwx.usage')) + return True + parts = [parts[0], location_str] if option_word: parts.append(option_word) @@ -458,35 +414,12 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): mqtt_topic = self._get_custom_mqtt_weather_topic(location) if mqtt_topic: self.logger.info(f"Using custom MQTT weather topic for location '{location}': {mqtt_topic}") - try: - self.record_execution(message.sender_id) - weather_data = self._mqtt_weather_line(mqtt_topic, forecast_type, location) - if forecast_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error reading MQTT weather: {e}") - await self.send_response(message, self.translate("commands.gwx.error", error=str(e))) - return True + return await self._reply_from_mqtt(message, mqtt_topic, forecast_type, location) # Check for custom WXSIM source first (before normal geocoding) wxsim_source = self._get_custom_wxsim_source(location) if wxsim_source: - # Use custom WXSIM source - try: - self.record_execution(message.sender_id) - # Blocking HTTP fetch of the WXSIM plaintext file. - weather_data = await asyncio.to_thread( - self._get_wxsim_weather, wxsim_source, forecast_type, num_days, - message, location, - ) - if forecast_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error fetching WXSIM weather: {e}") - await self.send_response(message, self.translate('commands.gwx.error', error=str(e))) - return True + return await self._reply_from_wxsim(message, wxsim_source, forecast_type, num_days, location) if forecast_type == "alerts": await self.send_response(message, self.translate('commands.gwx.source_option_not_available')) @@ -499,25 +432,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): # Get weather data for the location weather_data = await self.get_weather_for_location(location, forecast_type, num_days, message) - # Check if we need to send multiple messages (for alerts) - if isinstance(weather_data, tuple) and weather_data[0] == "multi_message": - # Send weather data first; if it was refused (rate limit, send failure), - # do not send its second part on its own. - if not await self.send_response(message, weather_data[1]): - return False - - # Wait for bot TX rate limiter - rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) - sleep_time = max(rate_limit + 1.0, 2.0) - await self._pace_reply(message, sleep_time) - - # Send alerts - # Second part of the same reply: the reply limiter already let the first through. - return bool(await self.send_response(message, weather_data[2], skip_user_rate_limit=True)) - if forecast_type == "multiday": - # Use message splitting for multi-day forecasts - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) + return await self._send_weather_reply(message, weather_data, forecast_type) except Exception as e: self.logger.error(f"Error in global weather command: {e}") diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 32a62c7..7dbfc38 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -561,6 +561,18 @@ class WxCommand(WeatherCommandMixin, BaseCommand): return f"{location_name}: {current}" return current + async def _fetch_wxsim( + self, source: str, forecast_type: str, num_days: int, message: MeshMessage, location: Optional[str] + ) -> str: + if location is None: + return await self._get_wxsim_weather_async(source, forecast_type, num_days, message) + return await self._get_wxsim_weather_async( + source, forecast_type, num_days, message, location_name=location + ) + + async def _fallback_place_name(self, lat: float, lon: float) -> Optional[str]: + return await self._coordinates_to_location_string_async(lat, lon) + async def _get_wxsim_weather_async( self, source_url: str, @@ -646,88 +658,18 @@ class WxCommand(WeatherCommandMixin, BaseCommand): if len(parts) < 2: mqtt_topic = self._get_custom_mqtt_weather_topic(None) if mqtt_topic: - try: - self.record_execution(message.sender_id) - weather_data = self._mqtt_weather_line(mqtt_topic, option_type, None) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error reading MQTT weather: {e}") - await self.send_response(message, self.translate("commands.wx.error", error=str(e))) - return True + return await self._reply_from_mqtt(message, mqtt_topic, option_type, None, split_multiday=False) wxsim_source = self._get_custom_wxsim_source(None) # Check for default if wxsim_source: - # Use custom WXSIM default source - try: - self.record_execution(message.sender_id) - weather_data = await self._get_wxsim_weather_async( - wxsim_source, option_type, option_days, message - ) - if option_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error fetching WXSIM weather: {e}") - await self.send_response(message, self.translate('commands.wx.error', error=str(e))) - return True + return await self._reply_from_wxsim(message, wxsim_source, option_type, option_days) - # No custom source, try companion location - companion_location = self._get_companion_location(message) - if companion_location: - # Use coordinates directly to avoid re-geocoding issues - location_str = self._coordinates_query(*companion_location) - parts = [parts[0], location_str] - using_companion_location = True - # Get city name for display - display_name = await self._coordinates_to_location_string_async( - companion_location[0], companion_location[1] - ) - if display_name: - self.logger.info(f"Using companion location: {display_name} ({companion_location[0]}, {companion_location[1]})") - else: - self.logger.info(f"Using companion coordinates: {location_str}") - else: - # 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: - # 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_query(*bot_loc) - parts = [parts[0], location_str] - display_name = await self._coordinates_to_location_string_async( - 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: - 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 + # No custom source: the sender's position, default city, then the bot's position + location_str, using_companion_location = await self._no_location_fallback(message) + if location_str is None: + await self.send_response(message, self.translate('commands.wx.usage')) + return True + parts = [parts[0], location_str] if option_word: parts.append(option_word) @@ -758,42 +700,14 @@ class WxCommand(WeatherCommandMixin, BaseCommand): mqtt_topic = self._get_custom_mqtt_weather_topic(location) if mqtt_topic: self.logger.info(f"Using custom MQTT weather topic for location '{location}': {mqtt_topic}") - try: - self.record_execution(message.sender_id) - weather_data = self._mqtt_weather_line( - mqtt_topic, forecast_type, location - ) - if forecast_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error reading MQTT weather: {e}") - await self.send_response(message, self.translate("commands.wx.error", error=str(e))) - return True + return await self._reply_from_mqtt(message, mqtt_topic, forecast_type, location) # Check for custom WXSIM source first (before checking location type) wxsim_source = self._get_custom_wxsim_source(location) if wxsim_source: self.logger.info(f"Using custom WXSIM source for location '{location}': {wxsim_source}") - # Use custom WXSIM source - try: - self.record_execution(message.sender_id) - weather_data = await self._get_wxsim_weather_async( - wxsim_source, - forecast_type, - num_days, - message, - location_name=location, - ) - if forecast_type == "multiday": - return await self._send_multiday_forecast(message, weather_data) - return bool(await self.send_response(message, weather_data)) - except Exception as e: - self.logger.error(f"Error fetching WXSIM weather: {e}") - await self.send_response(message, self.translate('commands.wx.error', error=str(e))) - return True - else: - self.logger.debug(f"No custom WXSIM source found for location '{location}', using normal weather API") + return await self._reply_from_wxsim(message, wxsim_source, forecast_type, num_days, location) + self.logger.debug(f"No custom WXSIM source found for location '{location}', using normal weather API") # Check if it's coordinates, zipcode, or city name if re.match(r'^\s*-?\d+\.?\d*\s*,\s*-?\d+\.?\d*\s*$', location): @@ -847,28 +761,7 @@ class WxCommand(WeatherCommandMixin, BaseCommand): # Get weather data for the location weather_data = await self.get_weather_for_location(location, location_type, forecast_type, num_days, message, using_companion_location=using_companion_location) - # Check if we need to send multiple messages - if isinstance(weather_data, tuple) and weather_data[0] == "multi_message": - # Send weather data first; if it was refused (rate limit, send failure), - # do not send its second part on its own. - if not await self.send_response(message, weather_data[1]): - return False - - # Wait for bot TX rate limiter to allow next message - rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) - # Use a conservative sleep time to avoid rate limiting - sleep_time = max(rate_limit + 1.0, 2.0) # At least 2 seconds, or rate_limit + 1 second - await self._pace_reply(message, sleep_time) - - # Send the special weather statement (already formatted with prioritization) - alert_text = weather_data[2] - # Second part of the same reply: the reply limiter already let the first through. - return bool(await self.send_response(message, alert_text, skip_user_rate_limit=True)) - if forecast_type == "multiday": - # Use message splitting for multi-day forecasts - return await self._send_multiday_forecast(message, weather_data) - # Send single message as usual - return bool(await self.send_response(message, weather_data)) + return await self._send_weather_reply(message, weather_data, forecast_type) except Exception as e: self.logger.error(f"Error in weather command: {e}") diff --git a/modules/weather_common.py b/modules/weather_common.py index 0b98e7f..0c8c595 100644 --- a/modules/weather_common.py +++ b/modules/weather_common.py @@ -62,7 +62,14 @@ class WeatherCommandMixin: translate: Any send_response: Any get_max_message_length: Any + get_config_value: Any + record_execution: Any _get_custom_wxsim_source: Any + _get_companion_location: Any + _get_bot_location: Any + default_city: Any + default_state: Any + default_country: Any @staticmethod def _coordinates_query(lat: float, lon: float) -> str: @@ -259,3 +266,136 @@ class WeatherCommandMixin: sent_all = False break return sent_all + + async def _reply_from_mqtt( + self, + message: Any, + topic: str, + forecast_type: str, + location: Optional[str], + *, + split_multiday: bool = True, + ) -> bool: + """Answer from a custom.mqtt_weather source; errors become the error reply.""" + try: + self.record_execution(message.sender_id) + weather_data = self._mqtt_weather_line(topic, forecast_type, location) + if split_multiday and forecast_type == "multiday": + return await self._send_multiday_forecast(message, weather_data) + return bool(await self.send_response(message, weather_data)) + except Exception as e: + self.logger.error(f"Error reading MQTT weather: {e}") + await self.send_response(message, self.translate(f"{self.translation_ns}.error", error=str(e))) + return True + + async def _reply_from_wxsim( + self, + message: Any, + source: str, + forecast_type: str, + num_days: int, + location: Optional[str] = None, + ) -> bool: + """Answer from a custom.wxsim source; errors become the error reply.""" + try: + self.record_execution(message.sender_id) + weather_data = await self._fetch_wxsim(source, forecast_type, num_days, message, location) + if forecast_type == "multiday": + return await self._send_multiday_forecast(message, weather_data) + return bool(await self.send_response(message, weather_data)) + except Exception as e: + self.logger.error(f"Error fetching WXSIM weather: {e}") + await self.send_response(message, self.translate(f"{self.translation_ns}.error", error=str(e))) + return True + + async def _fetch_wxsim( + self, source: str, forecast_type: str, num_days: int, message: Any, location: Optional[str] + ) -> str: + """Fetch and format a WXSIM source off the event loop (each command's own way).""" + raise NotImplementedError + + async def _fallback_place_name(self, lat: float, lon: float) -> Optional[str]: + """A place name for logging a fallback position, or None to log the coordinates.""" + return None + + def _default_city_query(self) -> Optional[str]: + """``default_city[, default_state][, default_country]``, or None when unset.""" + if not self.default_city: + return None + 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) + return ", ".join(location_parts) + + async def _no_location_fallback(self, message: Any) -> tuple[Optional[str], bool]: + """Where to forecast when the request names no place. + + Tries the sender's position, then default_city, then (when + use_bot_location_when_no_location is set) the bot's position. Returns + (location query or None, whether it is the sender's position). + """ + companion_location = self._get_companion_location(message) + if companion_location: + # Use coordinates directly to avoid re-geocoding issues + location_str = self._coordinates_query(*companion_location) + display_name = await self._fallback_place_name(companion_location[0], companion_location[1]) + if display_name: + self.logger.info(f"Using companion location: {display_name} ({companion_location[0]}, {companion_location[1]})") + else: + self.logger.info(f"Using companion coordinates: {location_str}") + return location_str, True + + default_city = self._default_city_query() + if default_city: + self.logger.info(f"Using default city (no args): {default_city}") + return default_city, False + + 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_query(*bot_loc) + display_name = await self._fallback_place_name(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}") + return location_str, False + + 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") + return None, False + + async def _send_weather_reply(self, message: Any, weather_data: Any, forecast_type: str) -> bool: + """Send a provider's answer: two parts with alerts, split by day, or one message.""" + if isinstance(weather_data, tuple) and weather_data[0] == "multi_message": + # Send weather data first; if it was refused (rate limit, send failure), + # do not send its second part on its own. + if not await self.send_response(message, weather_data[1]): + return False + + # Wait for bot TX rate limiter to allow next message + rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) + # Use a conservative sleep time to avoid rate limiting + sleep_time = max(rate_limit + 1.0, 2.0) # At least 2 seconds, or rate_limit + 1 second + await self._pace_reply(message, sleep_time) + + # Second part of the same reply: the reply limiter already let the first through. + return bool(await self.send_response(message, weather_data[2], skip_user_rate_limit=True)) + if forecast_type == "multiday": + # Use message splitting for multi-day forecasts + return await self._send_multiday_forecast(message, weather_data) + return bool(await self.send_response(message, weather_data))