From c5b6ebe077f950e67a6a20e43e36bdcfe3392408 Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 13:54:44 +0400 Subject: [PATCH 01/11] feat(i18n): localize Weather_Service proactive messages - Add _translate() helper to WeatherService using self.bot.translator - Replace all hardcoded English strings with services.weather_service.* keys - Add services.weather_service section to en.json (fallback for all locales) - Add ru.json with full Russian translation (~450 keys) --- modules/service_plugins/weather_service.py | 131 ++-- translations/en.json | 85 +++ translations/ru.json | 710 +++++++++++++++++++++ 3 files changed, 867 insertions(+), 59 deletions(-) create mode 100644 translations/ru.json diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 2182b21..0ff7378 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -198,6 +198,21 @@ class WeatherService(BaseServicePlugin): self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}") + def _translate(self, key: str, **kwargs: Any) -> str: + """Translate a key using the bot's translator. + + Args: + key: Dot-separated key path (e.g., 'services.weather_service.daily_weather'). + **kwargs: Formatting parameters for str.format(). + + Returns: + Translated string, or key if translation not found. + """ + translator = getattr(self.bot, 'translator', None) + if translator is not None: + return translator.translate(key, **kwargs) + return key + def _load_weather_model(self) -> Optional[str]: """Load and normalize Open-Meteo model selection from config. @@ -518,11 +533,13 @@ class WeatherService(BaseServicePlugin): # Get weather forecast forecast_text = await self._get_weather_forecast() - if forecast_text and forecast_text != "Error fetching weather data": + error_label = self._translate('services.weather_service.error_fetching') + if forecast_text and forecast_text != error_label: # Send to configured channel + daily_label = self._translate('services.weather_service.daily_weather') await self.bot.command_manager.send_channel_message( self.weather_channel, - f"🌤️ Daily Weather: {forecast_text}", + f"🌤️ {daily_label}: {forecast_text}", scope=self.get_mesh_flood_scope(), ) self.logger.info(f"Daily weather forecast sent to {self.weather_channel}") @@ -564,17 +581,17 @@ class WeatherService(BaseServicePlugin): ) if not response.ok: self.logger.warning(f"Error fetching weather from Open-Meteo: HTTP {response.status_code}") - return "Error fetching weather data" + return self._translate('services.weather_service.error_fetching') except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: self.logger.warning(f"Timeout/connection error fetching weather: {e}") - return "Error fetching weather data" + return self._translate('services.weather_service.error_fetching') # Extract current conditions current = data.get('current', {}) daily = data.get('daily', {}) if not current or not daily: - return "No forecast data available" + return self._translate('services.weather_service.no_data') # Current conditions temp = int(current.get('temperature_2m', 0)) @@ -625,7 +642,7 @@ class WeatherService(BaseServicePlugin): # Format current forecast forecast_text = f"{location_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}" if wind_speed > 0: - wind_dir_str = f"{wind_direction}" if wind_direction else "" + wind_dir_str = f"{wind_direction} " if wind_direction else "" forecast_text += f" {wind_dir_str}{wind_speed}{self.wind_speed_unit}" today_high = int(daily['temperature_2m_max'][0]) @@ -633,7 +650,8 @@ class WeatherService(BaseServicePlugin): forecast_text += ( " | " + format_temperature_high_low( - self.bot.config, today_high, today_low, temp_symbol, self.logger + self.bot.config, today_high, today_low, temp_symbol, self.logger, + translator=getattr(self.bot, 'translator', None), ) ) @@ -651,6 +669,7 @@ class WeatherService(BaseServicePlugin): tomorrow_emoji = self._get_weather_emoji(tomorrow_code) if tomorrow_max is not None: + tomorrow_label = self._translate('services.weather_service.tomorrow') if tomorrow_min is not None and tomorrow_min != tomorrow_max: hl = format_temperature_high_low( self.bot.config, @@ -658,8 +677,9 @@ class WeatherService(BaseServicePlugin): tomorrow_min, temp_symbol, self.logger, + translator=getattr(self.bot, 'translator', None), ) - forecast_text += f" | Tomorrow: {tomorrow_emoji}{tomorrow_desc} {hl}" + forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}" else: hl = format_temperature_high_low( self.bot.config, @@ -667,8 +687,9 @@ class WeatherService(BaseServicePlugin): None, temp_symbol, self.logger, + translator=getattr(self.bot, 'translator', None), ) - forecast_text += f" | Tomorrow: {tomorrow_emoji}{tomorrow_desc} {hl}" + forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}" return forecast_text @@ -676,7 +697,7 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error getting weather forecast: {e}") import traceback self.logger.debug(traceback.format_exc()) - return "Error fetching weather data" + return self._translate('services.weather_service.error_fetching') def _degrees_to_direction(self, degrees: float) -> str: """Convert wind direction in degrees to compass direction. @@ -685,7 +706,7 @@ class WeatherService(BaseServicePlugin): degrees: Wind direction in degrees (0-360). Returns: - str: Compass direction (e.g., 'N', 'NE', 'SW'). + str: Compass direction (e.g., 'N', 'NE', 'SW') in the current language. """ if degrees is None: return "" @@ -693,7 +714,8 @@ class WeatherService(BaseServicePlugin): directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] index = int((degrees + 11.25) / 22.5) % 16 - return directions[index] + key = directions[index] + return self._translate(f'services.weather_service.wind_directions.{key}') def _get_weather_description(self, code: int) -> str: """Get weather description from WMO weather code. @@ -704,20 +726,7 @@ class WeatherService(BaseServicePlugin): Returns: str: Human-readable weather description. """ - # WMO Weather interpretation codes (WW) - codes = { - 0: "Clear", 1: "Mostly Clear", 2: "Partly Cloudy", 3: "Overcast", - 45: "Foggy", 48: "Depositing Rime Fog", - 51: "Light Drizzle", 53: "Moderate Drizzle", 55: "Dense Drizzle", - 56: "Light Freezing Drizzle", 57: "Dense Freezing Drizzle", - 61: "Slight Rain", 63: "Moderate Rain", 65: "Heavy Rain", - 66: "Light Freezing Rain", 67: "Heavy Freezing Rain", - 71: "Slight Snow", 73: "Moderate Snow", 75: "Heavy Snow", - 77: "Snow Grains", 80: "Slight Rain Showers", 81: "Moderate Rain Showers", - 82: "Violent Rain Showers", 85: "Slight Snow Showers", 86: "Heavy Snow Showers", - 95: "Thunderstorm", 96: "Thunderstorm w/Hail", 99: "Severe Thunderstorm" - } - return codes.get(code, "Unknown") + return self._translate(f'services.weather_service.weather_descriptions.{code}') def _get_weather_emoji(self, code: int) -> str: """Get weather emoji from WMO weather code. @@ -1005,7 +1014,8 @@ class WeatherService(BaseServicePlugin): kind is "starting" (rain incoming) or "ending" (rain about to stop). prob/temp_f add a probability and a borderline-temperature tag. """ - emoji, ptype = precip_descriptor(result.bucket) + emoji, _ptype_en = precip_descriptor(result.bucket) + ptype = self._translate(f'commands.rain.precip_types.{result.bucket or "rain"}') # City + state/country (same labeling as the !rain command), reverse- # geocoded once and cached. Kept separate from the daily-forecast cache. @@ -1018,7 +1028,8 @@ class WeatherService(BaseServicePlugin): ), ) self._cached_rain_location = join_location(city, suffix) - location = f" near {self._cached_rain_location}" if self._cached_rain_location else "" + near_label = self._translate('services.weather_service.near') + location = f" {near_label} {self._cached_rain_location}" if self._cached_rain_location else "" parts = [] if self.rain_nowcast_show_amount: @@ -1026,17 +1037,24 @@ class WeatherService(BaseServicePlugin): result.bucket, result.amount_mm, result.snow_cm, self.rain_nowcast_amount_unit ) if amt: - parts.append(f"est {amt}") + est_label = self._translate('services.weather_service.est') + parts.append(f"{est_label} {amt}") if prob is not None: parts.append(f"{prob}%") est = f" ({', '.join(parts)})" if parts else "" temp = f" {temp_f}°F" if (temp_f is not None and 30 <= temp_f <= 38) else "" if kind == "ending": - return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{est}{temp}{location}" + return self._translate( + 'services.weather_service.rain_ending', + emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, temp=temp, location=location, + ) # Flag prolonged rain ("steady") rather than a numeric duration, which # would sit confusingly next to the minutes-until-start value. - steady = " (steady)" if result.open_ended else "" - return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{est}{steady}{temp}{location}" + steady_label = f" ({self._translate('services.weather_service.steady')})" if result.open_ended else "" + return self._translate( + 'services.weather_service.rain_starting', + emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, steady=steady_label, temp=temp, location=location, + ) async def _connect_blitzortung_mqtt(self) -> None: """Connect to Blitzortung MQTT broker and subscribe to lightning data. @@ -1615,12 +1633,9 @@ class WeatherService(BaseServicePlugin): }.get(severity, '⚪') # Format event type abbreviation - event_type_abbrev = { - 'Warning': 'Warn', - 'Watch': 'Watch', - 'Advisory': 'Adv', - 'Statement': 'Stmt' - }.get(event_type, event_type) + event_type_abbrev = self._translate( + f'services.weather_service.event_types.{event_type}', event_type=event_type + ) # Build compact alert string if include_details: @@ -1674,35 +1689,37 @@ class WeatherService(BaseServicePlugin): # Add expiration time if available if expires: + til_label = self._translate('services.weather_service.til') expires_compact = self._compact_time(expires) if any(month in expires_compact for month in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]): time_match = re.search(r'(\d+)(AM|PM)', expires_compact, re.IGNORECASE) if time_match: hour = time_match.group(1) am_pm = time_match.group(2) - expires_short = f" til {hour}{am_pm}" + expires_short = f" {til_label} {hour}{am_pm}" else: - expires_short = f" til {expires_compact[:15]}" + expires_short = f" {til_label} {expires_compact[:15]}" else: time_match = re.search(r'(\d+):?(\d+)?(AM|PM)', expires_compact, re.IGNORECASE) if time_match: hour = time_match.group(1) am_pm = time_match.group(3) - expires_short = f" til {hour}{am_pm}" + expires_short = f" {til_label} {hour}{am_pm}" else: - expires_short = f" til {expires_compact[:15]}" + expires_short = f" {til_label} {expires_compact[:15]}" result += expires_short # Add office if available (abbreviate city name) if office: + by_label = self._translate('services.weather_service.by') office_parts = office.split() if len(office_parts) >= 2: office_org = office_parts[0] city = office_parts[1] if len(office_parts) > 1 else "" city_abbrev = self._abbreviate_city_name(city) - office_short = f" by {office_org} {city_abbrev}" + office_short = f" {by_label} {office_org} {city_abbrev}" else: - office_short = f" by {office[:10]}" + office_short = f" {by_label} {office[:10]}" result += office_short # Add shortened URL if available and there's space (within 130 char limit) @@ -1736,26 +1753,24 @@ class WeatherService(BaseServicePlugin): if 'T' in time_str and re.match(r'\d{4}-\d{2}-\d{2}T', time_str): try: dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) - month_abbrevs = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - month = month_abbrevs[dt.month - 1] + month = self._translate(f'services.weather_service.months.{["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][dt.month - 1]}') day = dt.day hour = dt.hour if hour == 0: hour_12 = 12 - am_pm = "AM" + am_pm = self._translate('services.weather_service.am') elif hour < 12: hour_12 = hour - am_pm = "AM" + am_pm = self._translate('services.weather_service.am') elif hour == 12: hour_12 = 12 - am_pm = "PM" + am_pm = self._translate('services.weather_service.pm') else: hour_12 = hour - 12 - am_pm = "PM" + am_pm = self._translate('services.weather_service.pm') - return f"{month} {day} {hour_12}{am_pm}" + return f"{month} {day} {hour_12} {am_pm}" except Exception: pass @@ -1763,13 +1778,11 @@ class WeatherService(BaseServicePlugin): time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str) # Abbreviate month names - month_abbrev_map = { - "January": "Jan", "February": "Feb", "March": "Mar", "April": "Apr", - "May": "May", "June": "Jun", "July": "Jul", "August": "Aug", - "September": "Sep", "October": "Oct", "November": "Nov", "December": "Dec" - } - for full, abbrev in month_abbrev_map.items(): - time_str = time_str.replace(full, abbrev) + month_abbrevs = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + for abbrev in month_abbrevs: + translated = self._translate(f'services.weather_service.months.{abbrev}') + time_str = time_str.replace(abbrev, translated) # Remove "at" before time time_str = re.sub(r'\s+at\s+', ' ', time_str) diff --git a/translations/en.json b/translations/en.json index effd6d1..8efd39e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1357,6 +1357,8 @@ "no_help_available": "No help available for this command.", "snr_label": "SNR: {value} dB", "rssi_label": "RSSI: {value} dBm", + "temp_high_label": "H", + "temp_low_label": "L", "date_time": { "day_abbreviations": { "Mon": "Mon", @@ -1430,6 +1432,89 @@ "sender": "Sender: {sender}", "area": "Area: {areas}" } + }, + "weather_service": { + "daily_weather": "Daily Weather", + "tomorrow": "Tomorrow", + "error_fetching": "Error fetching weather data", + "no_data": "No forecast data available", + "near": "near", + "est": "est", + "steady": "steady", + "rain_starting": "{emoji} Heads up — {ptype} starting in ~{minutes}min{est}{steady}{temp}{location}", + "rain_ending": "{emoji} Heads up — {ptype} ending in ~{minutes}min{est}{temp}{location}", + "weather_descriptions": { + "0": "Clear", + "1": "Mostly Clear", + "2": "Partly Cloudy", + "3": "Overcast", + "45": "Foggy", + "48": "Depositing Rime Fog", + "51": "Light Drizzle", + "53": "Moderate Drizzle", + "55": "Dense Drizzle", + "56": "Light Freezing Drizzle", + "57": "Dense Freezing Drizzle", + "61": "Slight Rain", + "63": "Moderate Rain", + "65": "Heavy Rain", + "66": "Light Freezing Rain", + "67": "Heavy Freezing Rain", + "71": "Slight Snow", + "73": "Moderate Snow", + "75": "Heavy Snow", + "77": "Snow Grains", + "80": "Slight Rain Showers", + "81": "Moderate Rain Showers", + "82": "Violent Rain Showers", + "85": "Slight Snow Showers", + "86": "Heavy Snow Showers", + "95": "Thunderstorm", + "96": "Thunderstorm w/Hail", + "99": "Severe Thunderstorm" + }, + "event_types": { + "Warning": "Warn", + "Watch": "Watch", + "Advisory": "Adv", + "Statement": "Stmt" + }, + "months": { + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "May", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec" + }, + "am": "AM", + "pm": "PM", + "til": "til", + "by": "by", + "wind_directions": { + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW" + } } } } diff --git a/translations/ru.json b/translations/ru.json new file mode 100644 index 0000000..cd6e2c4 --- /dev/null +++ b/translations/ru.json @@ -0,0 +1,710 @@ +{ + "keywords": { + "help": ["help"], + "ping": ["ping"], + "test": ["test", "t"], + "wx": ["wx", "weather", "wxa", "wxalert"], + "gwx": ["gwx", "globalweather", "gwxa"], + "aqi": ["aqi", "air", "airquality", "air_quality"], + "rain": ["rain", "nowcast", "snow"], + "aurora": ["aurora", "kp"], + "solar": ["solar"], + "sun": ["sun"], + "moon": ["moon"], + "hfcond": ["hfcond", "hf"], + "satpass": ["satpass"], + "sports": ["sports", "score", "scores"], + "worldcup": ["wc", "worldcup"], + "stats": ["stats"], + "channels": ["channels", "channel"], + "path": ["path", "decode", "route"], + "prefix": ["prefix", "repeater", "lookup"], + "repeater": ["repeater", "repeaters", "rp"], + "solarforecast": ["sf", "solarforecast"], + "dice": ["dice"], + "roll": ["roll"], + "joke": ["joke", "jokes"], + "dadjoke": ["dadjoke", "dad joke", "dadjokes", "dad jokes"], + "catfact": ["catfact", "cat", "meow", "purr", "kitten"], + "hacker": ["hacker", "sudo", "ps aux", "grep", "ls -l", "ls -la", "echo $PATH"], + "hello": [ + "hello", "hi", "hey", "howdy", "greetings", "salutations", + "good morning", "good afternoon", "good evening", "good night", + "yo", "sup", "whats up", "what's up", "morning", "afternoon", + "evening", "night", "gday", "g'day", "hola", "bonjour", "ciao", + "namaste", "aloha", "shalom", "konnichiwa", "guten tag", + "buenos dias", "buenas tardes", "buenas noches" + ], + "webviewer": ["webviewer", "web", "viewer", "wv"], + "cmd": ["cmd", "commands"], + "advert": ["advert"], + "neighbors": ["neighbors", "neighbours"], + "multitest": ["multitest", "mt"], + "trace": ["trace", "tracer"] + }, + "commands": { + "help": { + "description": "Показывает команды. Используйте 'help ' для подробностей.", + "general": "Помощь MeshCore Bot\n\nДоступные команды:\n{commands_list}", + "unknown": "Неизвестная команда: '{command}'\n\nДоступные команды:\n{available}", + "specific": "Помощь для '{command}':\n{help_text}", + "no_help": "Справка недоступна", + "usage_examples": "\nПримеры:\n• `help @` - Справка по синтаксису @{string}\n• `help t` - Справка по синтаксису t {string}\n• `help advert` - Справка по команде advert\n• `help test` - Справка по команде test\n• `help ping` - Справка по команде ping", + "custom_syntax": "\nПользовательский синтаксис:\n• `t phrase` - Подтверждение с фразой (каналы и ЛС)\n• `@{string}` - Подтверждение со строкой (только ЛС)\n• `advert` - Отправить flood advert (только ЛС, кулдаун 1 час)" + }, + "ping": { + "description": "Отвечает на 'ping' сообщением 'Pong!'", + "response": "Pong!" + }, + "test": { + "description": "Отвечает на 'test' или 't' информацией о подключении", + "help": "Отвечает на 'test' или 't' информацией о подключении" + }, + "wx": { + "description": "Получить погоду по почтовому индексу (использование: wx 12345)", + "usage": "Использование: wx <индекс|город> - Пример: wx 12345 или wx seattle или wx paris, tx", + "subcommands": [ + {"name": "tomorrow", "description": "Прогноз на завтра"}, + {"name": "Nd", "description": "Прогноз на N дней (напр. 7d, 10d)"}, + {"name": "hourly", "description": "Почасовой прогноз"}, + {"name": "alerts", "description": "Полныеweather-оповещения"} + ], + "error_fetching": "Ошибка получения данных погоды из NOAA", + "no_location_zipcode": "Не удалось найти локацию для индекса {location}", + "no_location_city": "Не удалось найти город '{location}' в {state}", + "error": "Ошибка получения данных погоды: {error}", + "alerts": "{count} оповещений: {text}", + "tomorrow_not_available": "Прогноз на завтра недоступен", + "tomorrow_error": "Ошибка форматирования прогноза на завтра", + "multiday_not_available": "Прогноз на {num_days} дней недоступен", + "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", + "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", + "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен (включите [MqttWeather] и custom.mqtt_weather.* темы)", + "mqtt_weather_no_data": "Сообщение MQTT-погоды для этой темы ещё не получено", + "mqtt_weather_stale": "Данные MQTT-погоды слишком старые", + "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT-погоды: {detail}" + }, + "gwx": { + "description": "Получить погоду для любой точки мира (использование: gwx Tokyo)", + "help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)", + "usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France", + "error_fetching": "Ошибка получения данных погоды", + "error_fetching_api": "Ошибка получения данных погоды из Open-Meteo", + "no_location": "Не удалось найти локацию '{location}'", + "error": "Ошибка получения данных погоды: {error}", + "tomorrow_not_available": "Прогноз на завтра недоступен", + "tomorrow_error": "Ошибка форматирования прогноза на завтра", + "multiday_not_available": "Прогноз на {num_days} дней недоступен", + "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", + "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", + "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен", + "mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено", + "mqtt_weather_stale": "Данные MQTT-погоды устарели", + "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}", + "periods": { + "today": "Сегодня", + "tonight": "Сегодня ночью", + "tomorrow": "Завтра" + }, + "weather_descriptions": { + "0": "Ясно", + "1": "Преимущественно ясно", + "2": "Переменная облачность", + "3": "Пасмурно", + "45": "Туман", + "48": "Туман", + "51": "Лёгкая морось", + "53": "Морось", + "55": "Сильная морось", + "56": "Лёгкая ледяная морось", + "57": "Ледяная морось", + "61": "Небольшой дождь", + "63": "Дождь", + "65": "Сильный дождь", + "66": "Небольшой ледяной дождь", + "67": "Ледяной дождь", + "71": "Небольшой снег", + "73": "Снег", + "75": "Сильный снег", + "77": "Снежные зёрна", + "80": "Небольшие ливни", + "81": "Ливни", + "82": "Сильные ливни", + "85": "Небольшие снежные ливни", + "86": "Снежные ливни", + "95": "Гроза", + "96": "Гроза с градом", + "99": "Сильная гроза", + "unknown": "Неизвестно" + }, + "warnings": { + "extreme_heat": "⚠️ Экстремальная жара", + "extreme_cold": "⚠️ Экстремальный холод", + "heavy_rain": "⚠️ Сильный дождь", + "thunderstorms": "⚠️ Грозы", + "heavy_snow": "⚠️ Сильный снегопад", + "high_winds": "⚠️ Сильный ветер ({wind_speed} м/ч)" + } + }, + "sports": { + "description": "Получить спортивные результаты (использование: sports [команда/лига])", + "help": "Спортивные результаты. 'sports' для команд по умолчанию, 'sports [команда]' для конкретной команды.", + "error_fetching": "Ошибка получения спортивных данных", + "error_fetching_league": "Ошибка получения данных {sport}", + "error_fetching_team": "Ошибка получения данных для {team}", + "no_default_teams": "Команды по умолчанию не настроены", + "no_games_default": "Игры для команд по умолчанию не найдены", + "no_teams_city": "Команды для {city} не найдены", + "no_games_city": "Игры для команд {city} не найдены", + "no_games_league": "Игры для {sport} не найдены", + "no_games_team": "Игры для {team} не найдены", + "team_not_found": "Команда/Лига '{team}' не найдена. Попробуйте: seahawks, mariners, lfc, mlb, nfl и т.д." + }, + "worldcup": { + "description": "Счёт чемпионата мира FIFA, турнирная таблица", + "help": "Счёт чемпионата мира. 'wc' - сегодняшние игры, 'wc group A' - таблица, 'wc [страна]' - матчи команды.", + "not_in_season": "Чемпионат мира сейчас не проводится.", + "no_games_today": "Сегодня нет игр чемпионата мира.", + "no_games_nation": "Матчи для {nation} не найдены.", + "groups_list": "Группы ЧМ: {groups}. Попробуйте 'wc group A'.", + "group_not_found": "Группа '{group}' не найдена. Попробуйте букву группы, напр. 'wc group A'.", + "nation_not_found": "Страна '{nation}' не найдена в текущем чемпионате мира.", + "error_fetching": "Ошибка получения данных чемпионата мира" + }, + "stats": { + "description": "Статистика за 24 часа. 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'.", + "help": "Статистика за 24ч. 'stats' (базовая), 'stats messages', 'stats channels', 'stats paths', 'stats adverts'", + "subcommands": [ + {"name": "messages", "description": "Статистика сообщений пользователей"}, + {"name": "channels", "description": "Активность каналов"}, + {"name": "paths", "description": "Самые длинные маршруты"}, + {"name": "adverts", "description": "Топ узлов по advert-пакетам"} + ], + "disabled": "Команда stats отключена", + "unknown_subcommand": "Неизвестно: {subcommand}. Используйте 'stats', 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'", + "error": "Ошибка получения статистики: {error}", + "error_bot_users": "Ошибка получения статистики пользователей: {error}", + "error_channels": "Ошибка получения статистики каналов: {error}", + "error_paths": "Ошибка получения статистики маршрутов: {error}", + "basic": { + "header": "Статистика бота (24ч):", + "commands": "Cmds: {count} | Ответов: {replies}", + "top_command": "Топ команда: {command}", + "top_user": "Топ пользователь: {user}", + "none": "Нет" + }, + "users": { + "header": "Пользователи бота (24ч):", + "none": "Нет пользователей" + }, + "channels": { + "header": "Топ каналов (24ч)", + "none": "Нет активности каналов", + "msg_singular": "сообщ.", + "msg_plural": "сообщ.", + "user_singular": "пользователь", + "user_plural": "пользователей", + "format": "{rank}. {channel}: {msg_count} {msg_text} | {user_count} {user_text}" + }, + "paths": { + "none": "Нет данных маршрутов", + "format": "{rank} {sender} {path}", + "synopsis": "{rank} {sender}: {hops} хопов" + }, + "adverts": { + "header": "Топ advert-узлов (24ч):", + "none": "Нет advert-активности", + "advert_singular": "advert", + "advert_plural": "adverts", + "format": "{rank}. {name}: {count} {advert_text}", + "hashes_label": "Хэши: {hashes}" + }, + "error_adverts": "Ошибка получения advert-статистики: {error}" + }, + "solarforecast": { + "description": "Прогноз выработки солнечных панелей", + "usage": "Использование: sf <локация|повторитель|координаты|индекс> [мощность] [азимут] [угол]", + "usage_short": "Использование: sf <локация> [Вт] [азимут] [угол]", + "panel_size_range": "Мощность панели должна быть 1-1000Вт", + "azimuth_range": "Азимут должен быть от -180 до 180 (0=юг)", + "angle_range": "Угол должен быть 0-90 градусов", + "no_location": "Не удалось найти локацию: {location}", + "error_fetching": "Ошибка получения прогноза", + "no_data": "Нет данных прогноза", + "error": "Ошибка: {error}", + "rate_limit": "Лимит запросов: попробуйте через 5 мин", + "labels": { + "today": "Сегодня:{wh:.0f}Wh", + "tomorrow": "Завтра:{wh:.0f}Wh", + "day_format": "{day}:{wh:.0f}Wh", + "peak": "Пик: {watts:.0f}Вт@{time}", + "peak_past": "Пик: {watts:.0f}Вт (прошёл)", + "hours_util": " ({hours}ч,{util:.0f}%)", + "hours_percent": " ({hours}ч,{percent:.0f}%)", + "hours_only": " ({hours}ч)", + "separator": " | " + } + }, + "advert": { + "description": "Отправить flood advert (только ЛС, кулдаун 1ч)", + "cooldown_active": "Кулдаун advert. Подождите {minutes} мин.", + "success": "Flood advert успешно отправлен!", + "error": "Ошибка отправки flood advert: {error}" + }, + "neighbors": { + "description": "Запуск цикла обнаружения соседей (только ЛС)", + "disabled": "Обнаружение соседей не включено. Установите neighbors_enabled = true в [PacketCapture].", + "busy": "Цикл обнаружения уже запущен. Попробуйте позже.", + "cooldown_active": "Цикл обнаружения недавно завершился. Подождите {minutes} мин.", + "started": "Обнаружение соседей - слушаем ~{seconds}с. Сообщу по завершении.", + "success": "Услышано {count} сосед(ей), лучший SNR {best_snr}. Записано {recorded} связь(ей).", + "published": "Опубликовано в {succeeded}/{attempted} брокер(ов).", + "none": "Обнаружение завершено, но ни один повторитель не ответил.", + "failed": "Обнаружение не запущено: {reason}.", + "error": "Ошибка обнаружения соседей: {error}" + }, + "path": { + "description": "Декодировать hex-данные маршрута", + "help": "Path: path [hex] - Декодировать маршрут. path - текущий маршрут.", + "no_valid_hex": "Не найдены валидные hex-значения.", + "no_path": "Нет информации о маршруте", + "error": "Ошибка обработки маршрута: {error}", + "error_decoding": "Ошибка декодирования маршрута: {error}", + "error_extracting": "Ошибка извлечения маршрута: {error}", + "path_prefix": "Маршрут: {path_string}", + "direct_connection": "Прямое соединение (0 хопов)", + "node_format": "{node_id}: {name}", + "node_unknown": "{node_id}: Неизвестно", + "node_collision": "{node_id}: {matches} повторителей", + "node_geographic": "{node_id}: {name} {confidence}", + "continuation_start": "...\n{line}", + "continuation_end": "\n...", + "truncation": "...", + "unknown_name": "Неизвестно", + "repeater_resolution_deferred": "Маршрут: {path}\n\nПодсказка: Идентификация повторителей требует {minimum_path_bytes}-битные маршруты." + }, + "multitest": { + "description": "Слушает 6 сек и собирает уникальные пути от входящих сообщений с тем же хэшем", + "help": "Слушает 6 сек и собирает уникальные пути от входящих сообщений с тем же хэшем" + }, + "trace": { + "description": "Трассировка ссылки (trace=ручной путь, tracer=круговой)", + "help": "trace [path] - ручной путь. tracer [path] - круговой. Путь: запятые 2-хекс. (напр. 01,7a,55)." + }, + "prefix": { + "description": "Поиск повторителей по двухсимвольному префиксу", + "help_api": "Поиск повторителей по префиксу. Использование: 'prefix 1A'.", + "help_no_api": "Поиск повторителей по префиксу (только локальная БД).", + "location_note": " (с названиями городов)", + "refresh_not_available": "Обновление недоступно - нет API. Только локальная БД.", + "cache_refreshed": "Кэш префиксов обновлён!", + "unable_determine_free": "Не удалось определить свободные префиксы. Попробуйте 'prefix refresh'.", + "invalid_format": "Неверный формат префикса. Ожидается {prefix_hex_chars} hex-символа.", + "no_repeaters_found": "Повторители с префиксом '{prefix}' не найдены", + "no_free_prefixes": "Свободные префиксы не найдены", + "available_prefixes": "Доступные префиксы ({shown} из {total} свободных):", + "generate_key": "Сгенерировать ключ: https://gessaman.com/mc-keygen", + "prefix_db_all": "Префикс {prefix}: {count} повторитель(ей)", + "prefix_db_recent": "Префикс {prefix}: {count} повторитель(ей) ({days})", + "older_entries_note": "(Записи могут быть старше {days} дней)", + "prefix_api": "Префикс {prefix} ({count} повторитель(ей)):", + "item_format": "{index}. {name}", + "source_domain": "Источник: {domain}", + "source_api": "Источник: API", + "device_repeater": " (Повторитель)", + "device_roomserver": " (Сервер комнат)" + }, + "repeater": { + "description": "Управление контактами повторителей (только ЛС)", + "admin_only": "Команда repeater требует прав администратора" + }, + "airplanes": { + "description": "Самолёты над головой", + "usage": "Использование: airplanes [локация] [опции]", + "subcommands": [{"name": "overhead", "description": "Ближайший самолёт над головой"}], + "no_location": "Нет локации. Укажите координаты: airplanes 47.6,-122.3", + "overhead_no_location": "Локация неизвестна. Отправьте 'advert' или укажите координаты.", + "no_aircraft": "Самолёты в радиусе {radius}мм не найдены", + "no_aircraft_filtered": "Самолёты по фильтрам в радиусе {radius}мм не найдены", + "api_error": "Не удалось получить данные. API может быть недоступен.", + "invalid_coords": "Неверные координаты. Формат: широта,долгота (напр. 47.6,-122.3)", + "error": "Ошибка: {error}" + }, + "aqi": { + "description": "Индекс качества воздуха для локации", + "usage": "Использование: aqi <город|район|координаты>", + "usage_help": "Использование: aqi <город|координаты|help>", + "error_fetching": "Ошибка получения данных IQВ", + "error_fetching_openmeteo": "Ошибка получения данных IQВ из OpenMeteo", + "no_data": "Данные IQВ недоступны", + "no_location": "Не удалось найти локацию: {location}", + "no_location_city": "Не удалось найти город '{location}' в {state}", + "no_location_city_international": "Не удалось найти город '{location}'", + "no_location_zipcode": "Не удалось найти индекс '{zipcode}'", + "error": "Ошибка получения данных IQВ: {error}", + "error_geocoding_zipcode": "Ошибка геокодирования индекса: {error}", + "error_formatting": "Ошибка форматирования данных IQВ", + "invalid_latitude": "Неверная широта: {lat}. Должна быть от -90 до 90.", + "invalid_longitude": "Неверная долгота: {lon}. Должна быть от -180 до 180.", + "invalid_coordinates_format": "Неверный формат координат: {location}. Формат: широта,долгота", + "pollutant_help": "IQВ: PM2.5=мелкие частицы, PM10=крупные, O3=озон, NO2=азот, CO=угарный, SO2=серный", + "categories": { + "good": "Хорошо", + "moderate": "Умеренно", + "unhealthy_sensitive": "Вредно для чувствительных", + "unhealthy": "Вредно", + "very_unhealthy": "Очень вредно", + "hazardous": "Опасно", + "fair": "Нормально", + "poor": "Плохо", + "very_poor": "Очень плохо", + "na": "Н/Д" + } + }, + "rain": { + "description": "Радар осадков: когда начнётся/прекратится дождь в ближайшие ~2ч", + "help_rain": "Радар дождя: когда начнётся/прекратится ~2ч. rain [город|индекс|координаты]. Также: snow", + "help_snow": "Радар снега: когда начнётся/прекратится ~2ч. snow [город|индекс|координаты]. Также: rain", + "usage": "Использование: rain|snow [город|индекс|координаты]", + "usage_short": "Использование: rain|snow [город|индекс|координаты]", + "starting": "{emoji} {ptype} начнётся через ~{minutes}мин{extra} в {location}", + "stopping": "{emoji} {ptype} прекратится через ~{minutes}мин в {location}", + "continuing": "{emoji} {ptype} продолжается {window}+ в {location}", + "clear": "☀️ Без {precip} в ближайшие {window} в {location}", + "changeover": "{from_emoji}→{to_emoji} {first} {when} → {second} через ~{minutes}мин в {location}", + "now": "сейчас", + "duration_for": " (~{duration}мин)", + "duration_open": " (непрерывно)", + "error_fetching": "Ошибка получения данных радара", + "no_location": "Использование: rain [город|индекс|координаты].", + "no_location_city": "Не удалось найти город '{location}' в {state}", + "no_location_zipcode": "Не удалось найти индекс '{location}'", + "error": "Ошибка: {error}", + "precip_types": { + "drizzle": "Морось", + "rain": "Дождь", + "heavy_rain": "Сильный дождь", + "freezing": "Ледяной дождь", + "snow": "Снег", + "showers": "Ливень", + "thunder": "Гроза" + } + }, + "aurora": { + "description": "Прогноз полярного сияния (индекс KP)", + "usage": "Использование: aurora [город|индекс|координаты]", + "usage_short": "Использование: aurora [город|индекс|координаты]", + "response": "Вероятность полярного сияния: {prob_indicator} {prob}% для {location}\nKp {kp} (на {kp_time}) | {status}", + "error_fetching": "Ошибка получения данных авроры", + "no_location": "Использование: aurora [город|индекс|координаты].", + "no_location_city": "Не удалось найти город '{location}' в {state}", + "no_location_zipcode": "Не удалось найти индекс '{location}'", + "error": "Ошибка: {error}", + "status": { + "quiet": "Спокойно", + "unsettled": "Нестабильно", + "g1_g2": "G1/G2", + "g3_severe": "G3+ Сильная буря" + } + }, + "solar": { + "description": "Солнечная активность и 상태 HF-диапазонов", + "response": "Солнце: {info}", + "error": "Ошибка получения солнечной информации: {error}", + "labels": { + "a_index": "A-индекс", + "k_index": "K-индекс", + "sunspots": "Солнечные пятна", + "xray_flux": "Рентгеновский поток", + "solar_flux": "Солнечный поток", + "signal_noise": "Шум сигнала", + "error_fetching": "Ошибка получения данных", + "no_xray_data": "Нет данных по рентгену", + "not_configured": "не настроено, обратитесь к администратору" + }, + "condensed_format": "A:{a_index} K:{k_index} Сп:{sunspots} Пот:{solar_flux} Рент:{xray_flux} Шум:{signal_noise}" + }, + "sun": { + "description": "Время восхода/заката", + "help": "Время восхода/заката и позиция солнца", + "response": "Солнце:\n{info}", + "error": "Ошибка получения информации о солнце: {error}", + "labels": { + "sunset": "Закат", + "sunrise": "Восход", + "rise": "Восход", + "set": "Закат", + "daylight": "Дневной свет", + "remaining": "Осталось", + "azimuth": "Азимут", + "altitude": "Высота" + } + }, + "moon": { + "description": "Фаза луны и время восхода/заката", + "error": "Ошибка получения информации о луне: {error}", + "format": "{phase} {illum}\nВосход:{rise} Заход:{set}", + "format_with_dates": "{phase} {illum}\nВосход:{rise} Заход:{set}\nПолнолуние:{full} Новолуние:{new}", + "fallback": "{info}", + "unknown_phase": "Неизвестно", + "unknown_illum": "Н/Д", + "labels": { + "moonrise": "Восход луны", + "set": "Заход", + "phase": "Фаза", + "fullmoon": "Полнолуние", + "newmoon": "Новолуние", + "az": "Азимут", + "alt": "Высота" + }, + "phases": { + "new_moon": "Новолуние🌑", + "waxing_crescent": "Растущий серп🌒", + "first_quarter": "Первая четверть🌓", + "waxing_gibbous": "Растущая луна🌔", + "full_moon": "Полнолуние🌕", + "waning_gibbous": "Убывающая луна🌖", + "last_quarter": "Последняя четверть🌗", + "waning_crescent": "Убывающий серп🌘" + } + }, + "hfcond": { + "description": "Условия распространения HF", + "help": "HF-условия для любительской радиосвязи", + "header": "HF-условия:\n{info}", + "error": "Ошибка получения HF-условий: {error}" + }, + "satpass": { + "description": "Информация о пролёте спутника", + "help": "Прогноз пролётов спутников", + "header": "Пролёт спутника:\n{pass_info}", + "error": "Ошибка получения информации о пролёте: {error}", + "no_satellite": "Укажите NORAD-номер или сокращение. Пример: satpass iss", + "usage_short": "satpass <спутник|NORAD> [visual]", + "help_header": "Информация о пролётах спутников\n\nИспользование: satpass ", + "category_weather": "Погода: ", + "category_stations": "Станции: ", + "category_telescopes": "Телескопы: ", + "category_other": "Другое: ", + "examples": "\nПримеры:\n• satpass iss\n• satpass noaa15\n• satpass 25544", + "labels": { + "geostationary": "{satname}: ГЕО (нет LEO пролёта)", + "no_passes": "{satname}: нет пролётов в ближайшие 10 дней", + "invalid_norad": "Укажите NORAD#. Пример: satpass 25544" + } + }, + "channels": { + "description": "Список каналов", + "help": "Список каналов. 'channels' - основные, 'channels list' - по категориям.", + "subcommands": [{"name": "list", "description": "Список каналов в категории"}], + "no_channels_for_category": "Каналы для '{category}' не настроены. Используйте 'channels'.", + "no_channels_configured": "Каналы не настроены. Обратитесь к администратору.", + "error_retrieving_channels": "Ошибка получения каналов: {error}", + "no_categories_configured": "Категории каналов не настроены.", + "error_retrieving_categories": "Ошибка получения категорий: {error}", + "no_description_available": "Описание отсутствует", + "channel_not_found": "Канал {channel} не найден. Используйте 'channels list'.", + "error_retrieving_channel_info": "Ошибка получения информации о канале: {error}", + "no_category_channels": "Каналы {category} не настроены", + "headers": { + "common_channels": "Основные каналы: ", + "common_channels_cont": "Основные каналы: ", + "available_categories": "Доступные категории: ", + "channels_cont": "Каналы (продолжение): ", + "category": "{category}: ", + "category_channels": "Каналы {category}: " + }, + "category_count": "{category} ({count} каналов)" + }, + "dice": { + "description": "Бросок кубика", + "help": "Бросок кубика. 'dice' - d6, 'dice d20' - d20, 'dice 2d6'.", + "invalid_dice_type": "Неверный тип кубика.", + "single_die": "d{sides}: {result}", + "multiple_dice": "{count}d{sides}: [{results}] = {total}" + }, + "roll": { + "description": "Случайное число от 1 до X (по умолчанию 100)", + "help": "Случайное число от 1 до X. 'roll' - 1-100, 'roll 50' - 1-50.", + "result": "Бросок 1-{max}: {result}", + "invalid_number": "Неверное число." + }, + "roll_old": { + "description": "Бросок кубика" + }, + "joke": { + "description": "Случайная шутка" + }, + "dadjoke": { + "description": "Случайная папаша-шутка" + }, + "catfact": { + "description": "Случайный факт о котах", + "error": "Мяу? Что-то пошло не так!" + }, + "hacker": { + "description": "Симуляция взлома с юмористическими ошибками" + }, + "hello": { + "description": "Отвечает на приветствия", + "help": "Отвечает на приветствия роботизированными ответами", + "response_format": "Я {bot_name}." + }, + "webviewer": { + "description": "Управление веб-просмотрщиком (только админ)" + }, + "cmd": { + "description": "Список доступных команд" + } + }, + "elapsed": { + "sync_device_clock": "Синхронизация часов устройства" + }, + "errors": { + "rate_limited": "Ограничение частоты. Подождите {seconds:.1f} сек", + "cooldown": "Команда '{command}' на кулдауне. Подождите {seconds} сек.", + "access_denied": "Доступ запрещён: команда '{command}' требует прав администратора", + "dm_only": "Команда '{command}' доступна только в ЛС", + "execution_error": "Ошибка выполнения {command}: {error}", + "unknown_command": "Неизвестная команда: {command}", + "no_response": "Нет ответа", + "generic_error": "Ошибка: {error}" + }, + "messages": { + "processing": "Обработка сообщения: {content}", + "command_executed": "Команда выполнена", + "keyword_matched": "Ключевое слово '{keyword}' совпало, отвечаю" + }, + "common": { + "unknown": "Неизвестно", + "unknown_routing": "Неизвестная маршрутизация", + "unknown_sender": "Неизвестно", + "unknown_path": "Неизвестно", + "unknown_timestamp": "Неизвестно", + "no_help_available": "Справка для этой команды недоступна.", + "snr_label": "SNR: {value} дБ", + "rssi_label": "RSSI: {value} дБм", + "temp_high_label": "В", + "temp_low_label": "Н", + "date_time": { + "day_abbreviations": { + "Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт", "Fri": "Пт", "Sat": "Сб", "Sun": "Вс" + }, + "days": { + "Monday": "Понедельник", "Tuesday": "Вторник", "Wednesday": "Среда", + "Thursday": "Четверг", "Friday": "Пятница", "Saturday": "Суббота", "Sunday": "Воскресенье" + }, + "month_abbreviations": { + "Jan": "янв", "Feb": "фев", "Mar": "мар", "Apr": "апр", + "May": "май", "Jun": "июн", "Jul": "июл", "Aug": "авг", + "Sep": "сен", "Oct": "окт", "Nov": "ноя", "Dec": "дек" + }, + "months": { + "January": "Январь", "February": "Февраль", "March": "Март", + "April": "Апрель", "May": "Май", "June": "Июнь", + "July": "Июль", "August": "Август", "September": "Сентябрь", + "October": "Октябрь", "November": "Ноябрь", "December": "Декабрь" + } + } + }, + "categories": { + "basic": "Основные команды", + "custom_syntax": "Пользовательский синтаксис", + "special": "Специальные команды", + "weather": "Погода", + "solar": "Солнце", + "sports": "Спорт", + "analytics": "Аналитика", + "meshcore_info": "Информация MeshCore", + "management": "Управление" + }, + "services": { + "darcmowas": { + "messagetype": { + "eu-alert-level-1": "Экстренное оповещение", + "eu-alert-level-2": "Экстремальная опасность", + "eu-alert-level-3": "Значительная опасность", + "eu-alert-level-4": "Информация об опасности", + "eu-test": "Тестовое оповещение" + }, + "fields": { + "sender": "Отправитель: {sender}", + "area": "Район: {areas}" + } + }, + "weather_service": { + "daily_weather": "Погода на сегодня", + "tomorrow": "Завтра", + "error_fetching": "Ошибка получения данных погоды", + "no_data": "Данные прогноза недоступны", + "near": "в районе", + "est": "ориент.", + "steady": "непрерывно", + "rain_starting": "{emoji} {ptype} начнётся через ~{minutes}мин{est}{steady}{temp}{location}", + "rain_ending": "{emoji} {ptype} прекратится через ~{minutes}мин{est}{temp}{location}", + "weather_descriptions": { + "0": "Ясно", + "1": "Преим. ясно", + "2": "Перем. облачность", + "3": "Пасмурно", + "45": "Туман", + "48": "Изморозь", + "51": "Лёгкая морось", + "53": "Морось", + "55": "Сильная морось", + "56": "Лёгкая лед. морось", + "57": "Лед. морось", + "61": "Небол. дождь", + "63": "Дождь", + "65": "Сильный дождь", + "66": "Небол. лед. дождь", + "67": "Лед. дождь", + "71": "Небол. снег", + "73": "Снег", + "75": "Сильный снег", + "77": "Снежные зёрна", + "80": "Небол. ливень", + "81": "Ливень", + "82": "Сильный ливень", + "85": "Небол. снеж. ливень", + "86": "Снеж. ливень", + "95": "Гроза", + "96": "Гроза с градом", + "99": "Сильная гроза" + }, + "event_types": { + "Warning": "Предупреждение", + "Watch": "Наблюдение", + "Advisory": "Рекомендация", + "Statement": "Заявление" + }, + "months": { + "Jan": "янв", "Feb": "фев", "Mar": "мар", "Apr": "апр", + "May": "май", "Jun": "июн", "Jul": "июл", "Aug": "авг", + "Sep": "сен", "Oct": "окт", "Nov": "ноя", "Dec": "дек" + }, + "am": "утра", + "pm": "дня", + "til": "до", + "by": "от", + "wind_directions": { + "N": "С", + "NNE": "ССВ", + "NE": "СВ", + "ENE": "ВСВ", + "E": "В", + "ESE": "ВЮВ", + "SE": "ЮВ", + "SSE": "ЮЮВ", + "S": "Ю", + "SSW": "ЮЮЗ", + "SW": "ЮЗ", + "WSW": "ЗЮЗ", + "W": "З", + "WNW": "ЗСЗ", + "NW": "СЗ", + "NNW": "ССЗ" + } + } + } +} From d0715936c7872ffa5d8f50752165853876586ccd Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 13:54:50 +0400 Subject: [PATCH 02/11] feat(i18n): add translator support to format_temperature_high_low - Add optional translator param to format_temperature_high_low() - Translate temperature labels (H/L) via common.temp_high_label/low_label - Translate wind directions via services.weather_service.wind_directions - Fix wind format spacing (direction and speed were concatenated) - Pass translator from Weather_Service, !wx, and !gwx callers --- .../commands/alternatives/wx_international.py | 3 ++- modules/commands/wx_command.py | 12 +++++++---- modules/utils.py | 20 +++++++++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 89a2932..ab223f6 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -110,7 +110,8 @@ class GlobalWxCommand(BaseCommand): def _format_high_low(self, high: Optional[Union[int, float]], low: Optional[Union[int, float]], temp_symbol: str) -> str: """Format high/low using [Weather] temperature_*_format templates.""" - return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger) + return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger, + translator=getattr(self.bot, 'translator', None)) def _load_weather_model(self) -> Optional[str]: """Load and normalize Open-Meteo model selection from config. diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index c06b543..29eab12 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -177,7 +177,8 @@ class WxCommand(BaseCommand): def _format_high_low(self, high: Optional[float], low: Optional[float], temp_symbol: str) -> str: """Format high/low using [Weather] temperature_*_format templates.""" - return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger) + return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger, + translator=getattr(self.bot, 'translator', None)) @staticmethod def _noaa_period_temp_symbol(period: dict) -> str: @@ -3342,7 +3343,8 @@ class WxCommand(BaseCommand): low_val = int(low) if _pair_ok(high_val, low_val): return format_temperature_high_low( - self.bot.config, high_val, low_val, units_str, self.logger + self.bot.config, high_val, low_val, units_str, self.logger, + translator=getattr(self.bot, 'translator', None), ) except ValueError: continue @@ -3353,7 +3355,8 @@ class WxCommand(BaseCommand): low_val = int(low_match.group(1)) if _single_ok(low_val): return format_temperature_high_low( - self.bot.config, None, low_val, units_str, self.logger + self.bot.config, None, low_val, units_str, self.logger, + translator=getattr(self.bot, 'translator', None), ) except ValueError: pass @@ -3364,7 +3367,8 @@ class WxCommand(BaseCommand): high_val = int(high_match.group(1)) if _single_ok(high_val): return format_temperature_high_low( - self.bot.config, high_val, None, units_str, self.logger + self.bot.config, high_val, None, units_str, self.logger, + translator=getattr(self.bot, 'translator', None), ) except ValueError: pass diff --git a/modules/utils.py b/modules/utils.py index 1c813ab..3ad47aa 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -114,18 +114,26 @@ def format_temperature_high_low( low: Optional[Union[int, float]], units_str: str, logger: Optional[Any] = None, + translator: Any = None, ) -> str: """Format a daily high/low pair (or single value) using [Weather] templates. Config keys (optional; defaults match prior bot behavior): - temperature_high_low_format — both values: {high}, {low}, {units} - temperature_high_only_format — {high}, {units} - temperature_low_only_format — {low}, {units} + temperature_high_low_format — both values: {high}, {low}, {units}, {high_label}, {low_label} + temperature_high_only_format — {high}, {units}, {high_label} + temperature_low_only_format — {low}, {units}, {low_label} """ + if translator is not None: + high_label = translator.translate("common.temp_high_label") + low_label = translator.translate("common.temp_low_label") + else: + high_label = "H" + low_label = "L" + section = "Weather" - default_pair = "H:{high}{units} L:{low}{units}" - default_high_only = "H:{high}{units}" - default_low_only = "L:{low}{units}" + default_pair = f"{high_label}:{{high}}{{units}} {low_label}:{{low}}{{units}}" + default_high_only = f"{high_label}:{{high}}{{units}}" + default_low_only = f"{low_label}:{{low}}{{units}}" def _norm(v: Optional[Union[int, float]]) -> Optional[int]: if v is None: From 89dc47f4d55c51b1b006f42127e3968ee1c22b60 Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 13:54:57 +0400 Subject: [PATCH 03/11] test: update rain proactive e2e tests for i18n support - Inject Translator('en') into bot.translator mock - Tests now exercise the translate() path instead of hardcoded strings --- tests/unit/test_rain_proactive_e2e.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_rain_proactive_e2e.py b/tests/unit/test_rain_proactive_e2e.py index 81a51ac..7a21413 100644 --- a/tests/unit/test_rain_proactive_e2e.py +++ b/tests/unit/test_rain_proactive_e2e.py @@ -16,6 +16,7 @@ import asyncio import configparser from unittest.mock import Mock +from modules.i18n import Translator from modules.service_plugins.weather_service import WeatherService from tests.unit._rain_harness import make_series @@ -47,6 +48,7 @@ def build_service(series, monkeypatch, *, overrides=None): bot.logger = Mock() bot.config = cfg bot.db_manager = Mock() + bot.translator = Translator("en", "translations/") sends: list[tuple[str, str]] = [] @@ -116,6 +118,7 @@ def test_nws_path_accepts_cache_ttl_without_crashing(monkeypatch): bot.logger = Mock() bot.config = cfg bot.db_manager = Mock() + bot.translator = Translator("en", "translations/") sends: list[tuple[str, str]] = [] From f20dac5f05e647d1b463413cdea5bea70f8d1330 Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 13:57:18 +0400 Subject: [PATCH 04/11] docs: add CHANGELOG entry for Weather_Service localization --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5b248..22abdba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ semantic versioning. ## [Unreleased] +### Added + +- Localized proactive weather messages (daily forecasts, rain nowcasts, weather + alerts) via `services.weather_service.*` translation keys. `WeatherService` now + uses the bot's `translator` instead of hardcoded English strings, so proactive + outputs respect the configured `language` setting — the same mechanism already + used by `!wx`, `!gwx`, `!rain` and other commands. + +- Russian (`ru`) translation for the full bot UI, including all weather service + keys, command keys, categories, and common strings. + +- `format_temperature_high_low()` now accepts an optional `translator` parameter. + When provided, the `H`/`L` temperature labels and compass wind directions are + locale-aware (`H`/`L` → `В`/`Н` in Russian; `WNW` → `ЗСЗ`). Passed from + `WeatherService`, `!wx`, and `!gwx` callers. + ### Fixed - Startup config-lint findings now go to the log file. They were printed to stderr From 77ceeb5695a1e5fba1dc90caa29474f373730bad Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 14:45:06 +0400 Subject: [PATCH 05/11] fix(i18n): translate wind speed unit labels in Weather_Service forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add services.weather_service.wind_speed_units.* keys (ms, mph, kmh, kn) - 'ms' -> 'м/с' in Russian, 'm/s' in English --- modules/service_plugins/weather_service.py | 3 ++- translations/en.json | 6 ++++++ translations/ru.json | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 0ff7378..2f7bd75 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -643,7 +643,8 @@ class WeatherService(BaseServicePlugin): forecast_text = f"{location_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}" if wind_speed > 0: wind_dir_str = f"{wind_direction} " if wind_direction else "" - forecast_text += f" {wind_dir_str}{wind_speed}{self.wind_speed_unit}" + wind_unit_label = self._translate(f'services.weather_service.wind_speed_units.{self.wind_speed_unit}') + forecast_text += f" {wind_dir_str}{wind_speed}{wind_unit_label}" today_high = int(daily['temperature_2m_max'][0]) today_low = int(daily['temperature_2m_min'][0]) diff --git a/translations/en.json b/translations/en.json index 8efd39e..84e78aa 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1514,6 +1514,12 @@ "WNW": "WNW", "NW": "NW", "NNW": "NNW" + }, + "wind_speed_units": { + "ms": "m/s", + "mph": "mph", + "kmh": "km/h", + "kn": "kn" } } } diff --git a/translations/ru.json b/translations/ru.json index cd6e2c4..273750d 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -704,6 +704,12 @@ "WNW": "ЗСЗ", "NW": "СЗ", "NNW": "ССЗ" + }, + "wind_speed_units": { + "ms": "м/с", + "mph": "mph", + "kmh": "км/ч", + "kn": "уз" } } } From 2940e6f3c4e6ec024d52c2c791da6c8626c2b55f Mon Sep 17 00:00:00 2001 From: custardpy Date: Wed, 26 Aug 2026 14:58:57 +0400 Subject: [PATCH 06/11] fix(i18n): shorten Russian weather descriptions and daily_weather label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Погода на сегодня' -> 'Погода' (-11 chars) - Trim 28 WMO descriptions: drop spaces, abbreviate adjectives - Total savings: ~44 chars per forecast message --- translations/ru.json | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/translations/ru.json b/translations/ru.json index 273750d..ced6094 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -633,7 +633,7 @@ } }, "weather_service": { - "daily_weather": "Погода на сегодня", + "daily_weather": "Погода", "tomorrow": "Завтра", "error_fetching": "Ошибка получения данных погоды", "no_data": "Данные прогноза недоступны", @@ -644,33 +644,33 @@ "rain_ending": "{emoji} {ptype} прекратится через ~{minutes}мин{est}{temp}{location}", "weather_descriptions": { "0": "Ясно", - "1": "Преим. ясно", - "2": "Перем. облачность", + "1": "Преим.ясно", + "2": "Перем.облач.", "3": "Пасмурно", "45": "Туман", - "48": "Изморозь", - "51": "Лёгкая морось", + "48": "Изморось", + "51": "Лёгк.морось", "53": "Морось", - "55": "Сильная морось", - "56": "Лёгкая лед. морось", - "57": "Лед. морось", - "61": "Небол. дождь", + "55": "Сильн.морось", + "56": "Лёгк.лед.морось", + "57": "Лед.морось", + "61": "Небол.дождь", "63": "Дождь", - "65": "Сильный дождь", - "66": "Небол. лед. дождь", - "67": "Лед. дождь", - "71": "Небол. снег", + "65": "Сильн.дождь", + "66": "Слаб.лед.дождь", + "67": "Лед.дождь", + "71": "Небол.снег", "73": "Снег", - "75": "Сильный снег", - "77": "Снежные зёрна", - "80": "Небол. ливень", + "75": "Сильн.снег", + "77": "Снеж.зерна", + "80": "Небол.ливень", "81": "Ливень", - "82": "Сильный ливень", - "85": "Небол. снеж. ливень", - "86": "Снеж. ливень", + "82": "Сильн.ливень", + "85": "Слаб.снеж.ливень", + "86": "Снеж.ливень", "95": "Гроза", - "96": "Гроза с градом", - "99": "Сильная гроза" + "96": "Гроза+град", + "99": "Сильн.гроза" }, "event_types": { "Warning": "Предупреждение", From 495e5cb5d72512e75dbdd9ef0a8f9c5bfa2ac359 Mon Sep 17 00:00:00 2001 From: custard Date: Fri, 28 Aug 2026 10:30:13 +0400 Subject: [PATCH 07/11] feat(i18n): localize gwx output (wind, humidity, dew point, visibility, pressure, day names) --- .../commands/alternatives/wx_international.py | 59 +++++++++++-------- translations/en.json | 51 ++++++++++------ translations/ru.json | 59 ++++++++++++------- 3 files changed, 105 insertions(+), 64 deletions(-) diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index ab223f6..8d5fa0b 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -1055,16 +1055,19 @@ class GlobalWxCommand(BaseCommand): # Add feels like if significantly different if abs(feels_like - temp) >= 5: - weather += f" (feels {feels_like}{temp_symbol})" + feels_str = self.translate('commands.gwx.feels_like', value=feels_like, unit=temp_symbol) + weather += f" {feels_str}" # Add wind info (always show if >= 3 mph, show gusts if significant) if wind_speed >= 3: weather += f" {wind_direction}{wind_speed}" if wind_gusts > wind_speed + 3: - weather += f"G{wind_gusts}" + gust_str = self.translate('commands.gwx.gust', value=wind_gusts) + weather += gust_str # Add humidity - weather += f" {humidity}%RH" + humidity_str = self.translate('commands.gwx.humidity', value=humidity) + weather += f" {humidity_str}" # Add additional conditions if space allows conditions = [] @@ -1072,7 +1075,8 @@ class GlobalWxCommand(BaseCommand): # Add dew point if dewpoint is not None: dewpoint_val = int(dewpoint) - conditions.append(f"💧{dewpoint_val}{temp_symbol}") + dew_str = self.translate('commands.gwx.dew_point', value=dewpoint_val, unit=temp_symbol) + conditions.append(dew_str) # Add visibility (already converted to miles above) if visibility_mi is not None and visibility_mi > 0: @@ -1080,12 +1084,14 @@ class GlobalWxCommand(BaseCommand): visibility_display = int(visibility_mi) if visibility_display > 20: visibility_display = 20 - conditions.append(f"👁️{visibility_display}mi") + vis_str = self.translate('commands.gwx.visibility', value=visibility_display) + conditions.append(vis_str) # Add pressure (convert from hPa to display format) if pressure is not None: pressure_hpa = int(pressure) - conditions.append(f"📊{pressure_hpa}hPa") + press_str = self.translate('commands.gwx.pressure', value=pressure_hpa) + conditions.append(press_str) # Add conditions to weather string if space allows # Reserve space for forecast data (high/low and tomorrow) @@ -1177,7 +1183,8 @@ class GlobalWxCommand(BaseCommand): if len(daily.get('wind_gusts_10m_max', [])) > 1: wind_gusts = int(daily['wind_gusts_10m_max'][1]) if wind_gusts > wind_speed + 3: - wind_info += f"G{wind_gusts}" + gust_str = self.translate('commands.gwx.gust', value=wind_gusts) + wind_info += gust_str # Get precipitation probability and amount precip_info = "" @@ -1231,13 +1238,13 @@ class GlobalWxCommand(BaseCommand): # Map day names to 1-2 letter abbreviations day_abbrev_map = { - 'Monday': 'M', - 'Tuesday': 'T', - 'Wednesday': 'W', - 'Thursday': 'Th', - 'Friday': 'F', - 'Saturday': 'Sa', - 'Sunday': 'Su' + 'Monday': self.translate('commands.gwx.day_abbrev.Monday'), + 'Tuesday': self.translate('commands.gwx.day_abbrev.Tuesday'), + 'Wednesday': self.translate('commands.gwx.day_abbrev.Wednesday'), + 'Thursday': self.translate('commands.gwx.day_abbrev.Thursday'), + 'Friday': self.translate('commands.gwx.day_abbrev.Friday'), + 'Saturday': self.translate('commands.gwx.day_abbrev.Saturday'), + 'Sunday': self.translate('commands.gwx.day_abbrev.Sunday') } parts = [] @@ -1360,20 +1367,24 @@ class GlobalWxCommand(BaseCommand): if degrees is None: return "" - directions = [ - (0, "⬆️N"), (22.5, "↗️NE"), (45, "↗️NE"), (67.5, "➡️E"), - (90, "➡️E"), (112.5, "↘️SE"), (135, "↘️SE"), (157.5, "⬇️S"), - (180, "⬇️S"), (202.5, "↙️SW"), (225, "↙️SW"), (247.5, "⬅️W"), - (270, "⬅️W"), (292.5, "↖️NW"), (315, "↖️NW"), (337.5, "⬆️N"), - (360, "⬆️N") + dir_emojis = [ + (0, "⬆️", "N"), (22.5, "↗️", "NE"), (45, "↗️", "NE"), (67.5, "➡️", "E"), + (90, "➡️", "E"), (112.5, "↘️", "SE"), (135, "↘️", "SE"), (157.5, "⬇️", "S"), + (180, "⬇️", "S"), (202.5, "↙️", "SW"), (225, "↙️", "SW"), (247.5, "⬅️", "W"), + (270, "⬅️", "W"), (292.5, "↖️", "NW"), (315, "↖️", "NW"), (337.5, "⬆️", "N"), + (360, "⬆️", "N") ] # Find closest direction - for i in range(len(directions) - 1): - if directions[i][0] <= degrees < directions[i + 1][0]: - return directions[i][1] + for i in range(len(dir_emojis) - 1): + if dir_emojis[i][0] <= degrees < dir_emojis[i + 1][0]: + emoji, key = dir_emojis[i][1], dir_emojis[i][2] + translated = self.translate(f"services.weather_service.wind_directions.{key}") + return f"{emoji}{translated}" - return "⬆️N" # Default to North + emoji, key = dir_emojis[-1][1], dir_emojis[-1][2] + translated = self.translate(f"services.weather_service.wind_directions.{key}") + return f"{emoji}{translated}" def _get_weather_description(self, code: int) -> str: """Convert WMO weather code to description. diff --git a/translations/en.json b/translations/en.json index 84e78aa..716ad07 100644 --- a/translations/en.json +++ b/translations/en.json @@ -232,24 +232,39 @@ "mqtt_weather_stale": "MQTT weather data is too old", "mqtt_weather_payload_error": "MQTT weather payload error: {detail}" }, - "gwx": { - "description": "Get weather information for any global location (usage: gwx Tokyo)", - "help": "Usage: gwx - Get weather for any global location (city, country, or coordinates)", - "usage": "Usage: gwx - Example: gwx Tokyo or gwx Paris, France", - "error_fetching": "Error fetching weather data", - "error_fetching_api": "Error fetching weather data from Open-Meteo", - "no_location": "Could not find location '{location}'", - "error": "Error getting weather data: {error}", - "tomorrow_not_available": "Tomorrow's forecast not available", - "tomorrow_error": "Error formatting tomorrow's forecast", - "multiday_not_available": "{num_days}-day forecast not available", - "multiday_error": "Error formatting {num_days}-day forecast", - "mqtt_forecast_not_supported": "Extended forecast is not available for MQTT weather sources", - "mqtt_weather_no_subscriber": "MQTT weather subscriber is not active (enable [MqttWeather] and custom.mqtt_weather.* topics)", - "mqtt_weather_no_data": "No MQTT weather message received for this topic yet", - "mqtt_weather_stale": "MQTT weather data is too old", - "mqtt_weather_payload_error": "MQTT weather payload error: {detail}", - "periods": { + "gwx": { + "description": "Get weather for any worldwide location (use: gwx Tokyo)", + "help": "Usage: gwx - Weather for any location worldwide (city, country, or coordinates)", + "usage": "Usage: gwx - Example: gwx Tokyo or gwx Paris, France", + "error_fetching": "Error fetching weather data", + "error_fetching_api": "Error fetching weather data from Open-Meteo", + "no_location": "Could not find location '{location}'", + "error": "Error fetching weather data: {error}", + "tomorrow_not_available": "Tomorrow forecast not available", + "tomorrow_error": "Error formatting tomorrow forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", + "mqtt_forecast_not_supported": "Extended forecast not supported for MQTT weather sources", + "mqtt_weather_no_subscriber": "MQTT weather subscriber not active", + "mqtt_weather_no_data": "MQTT weather message not yet received", + "mqtt_weather_stale": "MQTT weather data is stale", + "mqtt_weather_payload_error": "MQTT weather payload error: {detail}", + "feels_like": "(feels {value}{unit})", + "humidity": "{value}%RH", + "dew_point": "💧{value}{unit}", + "visibility": "👁️{value}mi", + "pressure": "📊{value}hPa", + "gust": "G{value}", + "day_abbrev": { + "Monday": "M", + "Tuesday": "T", + "Wednesday": "W", + "Thursday": "Th", + "Friday": "F", + "Saturday": "Sa", + "Sunday": "Su" + }, + "periods": { "today": "Today", "tonight": "Tonight", "tomorrow": "Tomorrow" diff --git a/translations/ru.json b/translations/ru.json index ced6094..f132239 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -84,28 +84,43 @@ "mqtt_weather_stale": "Данные MQTT-погоды слишком старые", "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT-погоды: {detail}" }, - "gwx": { - "description": "Получить погоду для любой точки мира (использование: gwx Tokyo)", - "help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)", - "usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France", - "error_fetching": "Ошибка получения данных погоды", - "error_fetching_api": "Ошибка получения данных погоды из Open-Meteo", - "no_location": "Не удалось найти локацию '{location}'", - "error": "Ошибка получения данных погоды: {error}", - "tomorrow_not_available": "Прогноз на завтра недоступен", - "tomorrow_error": "Ошибка форматирования прогноза на завтра", - "multiday_not_available": "Прогноз на {num_days} дней недоступен", - "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", - "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", - "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен", - "mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено", - "mqtt_weather_stale": "Данные MQTT-погоды устарели", - "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}", - "periods": { - "today": "Сегодня", - "tonight": "Сегодня ночью", - "tomorrow": "Завтра" - }, + "gwx": { + "description": "Получить погоду для любой точки мира (использование: gwx Tokyo)", + "help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)", + "usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France", + "error_fetching": "Ошибка получения данных погоды", + "error_fetching_api": "Ошибка получения данных погоды из Open-Meteo", + "no_location": "Не удалось найти локацию '{location}'", + "error": "Ошибка получения данных погоды: {error}", + "tomorrow_not_available": "Прогноз на завтра недоступен", + "tomorrow_error": "Ошибка форматирования прогноза на завтра", + "multiday_not_available": "Прогноз на {num_days} дней недоступен", + "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", + "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", + "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен", + "mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено", + "mqtt_weather_stale": "Данные MQTT-погоды устарели", + "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}", + "feels_like": "(ош.{value}{unit})", + "humidity": "{value}%", + "dew_point": "💧{value}{unit}", + "visibility": "👁️{value}миль", + "pressure": "📊{value}гПа", + "gust": "G{value}", + "day_abbrev": { + "Monday": "Пн", + "Tuesday": "Вт", + "Wednesday": "Ср", + "Thursday": "Чт", + "Friday": "Пт", + "Saturday": "Сб", + "Sunday": "Вс" + }, + "periods": { + "today": "Сегодня", + "tonight": "Сегодня ночью", + "tomorrow": "Завтра" + }, "weather_descriptions": { "0": "Ясно", "1": "Преимущественно ясно", From 558e5e78b8e4236c3b4dced788b25db560519f32 Mon Sep 17 00:00:00 2001 From: custardpy Date: Fri, 28 Aug 2026 10:36:42 +0400 Subject: [PATCH 08/11] fix(i18n): show gwx visibility in km for metric locales --- .../commands/alternatives/wx_international.py | 24 ++++++++++++++----- translations/en.json | 1 + translations/ru.json | 3 ++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 8d5fa0b..95d6410 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -19,7 +19,7 @@ from ...utils import ( get_nominatim_geocoder, rate_limited_nominatim_reverse_sync, ) -from ..base_command import BaseCommand +from ..base_command import BaseCommand, _response_translator # Import WXSIM parser for custom weather sources try: @@ -1080,11 +1080,23 @@ class GlobalWxCommand(BaseCommand): # Add visibility (already converted to miles above) if visibility_mi is not None and visibility_mi > 0: - # Cap visibility at 20 miles for display (beyond that is essentially unlimited) - visibility_display = int(visibility_mi) - if visibility_display > 20: - visibility_display = 20 - vis_str = self.translate('commands.gwx.visibility', value=visibility_display) + # Determine if the response locale uses metric (km) vs imperial (mi) + translator = _response_translator.get() or getattr(self.bot, 'translator', None) + base_lang = getattr(translator, 'base_language', 'en') or 'en' + metric = base_lang != 'en' + if metric: + # Convert miles to kilometers and cap at ~32 km (equivalent to 20 mi) + visibility_km = visibility_mi * 1.609344 + visibility_display = int(visibility_km) + if visibility_display > 32: + visibility_display = 32 + vis_str = self.translate('commands.gwx.visibility_km', value=visibility_display) + else: + # Cap visibility at 20 miles for display (beyond that is essentially unlimited) + visibility_display = int(visibility_mi) + if visibility_display > 20: + visibility_display = 20 + vis_str = self.translate('commands.gwx.visibility', value=visibility_display) conditions.append(vis_str) # Add pressure (convert from hPa to display format) diff --git a/translations/en.json b/translations/en.json index 716ad07..0504a41 100644 --- a/translations/en.json +++ b/translations/en.json @@ -253,6 +253,7 @@ "humidity": "{value}%RH", "dew_point": "💧{value}{unit}", "visibility": "👁️{value}mi", + "visibility_km": "👁️{value}km", "pressure": "📊{value}hPa", "gust": "G{value}", "day_abbrev": { diff --git a/translations/ru.json b/translations/ru.json index f132239..5ef9ddb 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -104,7 +104,8 @@ "feels_like": "(ош.{value}{unit})", "humidity": "{value}%", "dew_point": "💧{value}{unit}", - "visibility": "👁️{value}миль", + "visibility": "👁️{value}км", + "visibility_km": "👁️{value}км", "pressure": "📊{value}гПа", "gust": "G{value}", "day_abbrev": { From 721c878f73622f7dc7c6da8527f8ac2e36e2bd42 Mon Sep 17 00:00:00 2001 From: custardpy Date: Fri, 28 Aug 2026 10:43:40 +0400 Subject: [PATCH 09/11] fix(i18n): shorten Russian gwx weather descriptions --- translations/ru.json | 62 ++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/translations/ru.json b/translations/ru.json index 5ef9ddb..38a460f 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -122,37 +122,37 @@ "tonight": "Сегодня ночью", "tomorrow": "Завтра" }, - "weather_descriptions": { - "0": "Ясно", - "1": "Преимущественно ясно", - "2": "Переменная облачность", - "3": "Пасмурно", - "45": "Туман", - "48": "Туман", - "51": "Лёгкая морось", - "53": "Морось", - "55": "Сильная морось", - "56": "Лёгкая ледяная морось", - "57": "Ледяная морось", - "61": "Небольшой дождь", - "63": "Дождь", - "65": "Сильный дождь", - "66": "Небольшой ледяной дождь", - "67": "Ледяной дождь", - "71": "Небольшой снег", - "73": "Снег", - "75": "Сильный снег", - "77": "Снежные зёрна", - "80": "Небольшие ливни", - "81": "Ливни", - "82": "Сильные ливни", - "85": "Небольшие снежные ливни", - "86": "Снежные ливни", - "95": "Гроза", - "96": "Гроза с градом", - "99": "Сильная гроза", - "unknown": "Неизвестно" - }, + "weather_descriptions": { + "0": "Ясно", + "1": "Преим.ясно", + "2": "Перем.облач.", + "3": "Пасмурно", + "45": "Туман", + "48": "Туман", + "51": "Лёгк.морось", + "53": "Морось", + "55": "Сильн.морось", + "56": "Лёгк.лед.морось", + "57": "Лед.морось", + "61": "Небол.дождь", + "63": "Дождь", + "65": "Сильн.дождь", + "66": "Слаб.лед.дождь", + "67": "Лед.дождь", + "71": "Небол.снег", + "73": "Снег", + "75": "Сильн.снег", + "77": "Снеж.зерна", + "80": "Небол.ливень", + "81": "Ливень", + "82": "Сильн.ливень", + "85": "Слаб.снеж.ливень", + "86": "Снеж.ливень", + "95": "Гроза", + "96": "Гроза+град", + "99": "Сильн.гроза", + "unknown": "Неизвестно" + }, "warnings": { "extreme_heat": "⚠️ Экстремальная жара", "extreme_cold": "⚠️ Экстремальный холод", From 92441d0aef9b36f72b2536de1809c236b43790a3 Mon Sep 17 00:00:00 2001 From: custardpy Date: Sun, 30 Aug 2026 08:14:55 +0400 Subject: [PATCH 10/11] fix(i18n): show gwx pressure in mmHg for metric locales Render GWX pressure in mm Hg (rounded hPa->mmHg conversion) for non-English locales instead of hPa, matching the metric handling used for visibility. Also fix format_temperature_high_low so custom [Weather] templates can use the documented {high_label}/{low_label} placeholders, and add tests for the translator param. --- .../commands/alternatives/wx_international.py | 9 ++++- modules/utils.py | 14 ++++---- .../unit/test_temperature_high_low_format.py | 36 +++++++++++++++++++ translations/en.json | 1 + translations/ru.json | 1 + 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 95d6410..a1bed7e 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -1102,7 +1102,14 @@ class GlobalWxCommand(BaseCommand): # Add pressure (convert from hPa to display format) if pressure is not None: pressure_hpa = int(pressure) - press_str = self.translate('commands.gwx.pressure', value=pressure_hpa) + # Use mmHg for metric (non-English) locales; hPa for imperial/English + translator = _response_translator.get() or getattr(self.bot, 'translator', None) + base_lang = getattr(translator, 'base_language', 'en') or 'en' + if base_lang != 'en': + pressure_mmhg = round(pressure_hpa * 0.750062) + press_str = self.translate('commands.gwx.pressure_mmhg', value=pressure_mmhg) + else: + press_str = self.translate('commands.gwx.pressure', value=pressure_hpa) conditions.append(press_str) # Add conditions to weather string if space allows diff --git a/modules/utils.py b/modules/utils.py index 3ad47aa..4a9e4ae 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -165,22 +165,24 @@ def format_temperature_high_low( logger.warning("Invalid temperature format template %r: %s", fmt, e) return None + labels = {"high_label": high_label, "low_label": low_label} + if hi is not None and lo is not None: - out = _try_format(pair_fmt, high=hi, low=lo, units=units_str) + out = _try_format(pair_fmt, high=hi, low=lo, units=units_str, **labels) if out is not None: return out - return _try_format(default_pair, high=hi, low=lo, units=units_str) or f"H:{hi}{units_str} L:{lo}{units_str}" + return _try_format(default_pair, high=hi, low=lo, units=units_str, **labels) or f"{high_label}:{hi}{units_str} {low_label}:{lo}{units_str}" if hi is not None: - out = _try_format(high_only_fmt, high=hi, low=lo, units=units_str) + out = _try_format(high_only_fmt, high=hi, low=lo, units=units_str, **labels) if out is not None: return out - return _try_format(default_high_only, high=hi, low=lo, units=units_str) or f"H:{hi}{units_str}" + return _try_format(default_high_only, high=hi, low=lo, units=units_str, **labels) or f"{high_label}:{hi}{units_str}" - out = _try_format(low_only_fmt, high=hi, low=lo, units=units_str) + out = _try_format(low_only_fmt, high=hi, low=lo, units=units_str, **labels) if out is not None: return out - return _try_format(default_low_only, high=hi, low=lo, units=units_str) or f"L:{lo}{units_str}" + return _try_format(default_low_only, high=hi, low=lo, units=units_str, **labels) or f"{low_label}:{lo}{units_str}" def abbreviate_location(location: str, max_length: int = 20) -> str: diff --git a/tests/unit/test_temperature_high_low_format.py b/tests/unit/test_temperature_high_low_format.py index 973d975..7ae77ac 100644 --- a/tests/unit/test_temperature_high_low_format.py +++ b/tests/unit/test_temperature_high_low_format.py @@ -2,11 +2,16 @@ """Unit tests for format_temperature_high_low ([Weather] templates).""" import configparser +from pathlib import Path import pytest +from modules.i18n import Translator from modules.utils import format_temperature_high_low +REPO_ROOT = Path(__file__).resolve().parents[2] +TRANSLATIONS = str(REPO_ROOT / "translations") + @pytest.fixture def cfg(): @@ -53,3 +58,34 @@ def test_bad_template_falls_back(mock_logger): out = format_temperature_high_low(c, 10, 5, "°C", mock_logger) assert out == "H:10°C L:5°C" mock_logger.warning.assert_called() + + +def test_translator_en_labels(cfg): + translator = Translator(language="en", translation_path=TRANSLATIONS) + assert ( + format_temperature_high_low(cfg, 47, 33, "°F", None, translator=translator) + == "H:47°F L:33°F" + ) + + +def test_translator_ru_labels(cfg): + translator = Translator(language="ru", translation_path=TRANSLATIONS) + assert ( + format_temperature_high_low(cfg, 47, 33, "°C", None, translator=translator) + == "В:47°C Н:33°C" + ) + + +def test_translator_ru_high_only(cfg): + translator = Translator(language="ru", translation_path=TRANSLATIONS) + assert format_temperature_high_low(cfg, 46, None, "°C", None, translator=translator) == "В:46°C" + assert format_temperature_high_low(cfg, None, 38, "°C", None, translator=translator) == "Н:38°C" + + +def test_translator_uses_custom_template(cfg): + translator = Translator(language="ru", translation_path=TRANSLATIONS) + cfg.set("Weather", "temperature_high_low_format", "{high_label}{high}° / {low_label}{low}°") + assert ( + format_temperature_high_low(cfg, 47, 33, "°C", None, translator=translator) + == "В47° / Н33°" + ) diff --git a/translations/en.json b/translations/en.json index 0504a41..350402c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -255,6 +255,7 @@ "visibility": "👁️{value}mi", "visibility_km": "👁️{value}km", "pressure": "📊{value}hPa", + "pressure_mmhg": "📊{value}мм рт. ст.", "gust": "G{value}", "day_abbrev": { "Monday": "M", diff --git a/translations/ru.json b/translations/ru.json index 38a460f..0a5ba75 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -107,6 +107,7 @@ "visibility": "👁️{value}км", "visibility_km": "👁️{value}км", "pressure": "📊{value}гПа", + "pressure_mmhg": "📊{value} мм рт. ст.", "gust": "G{value}", "day_abbrev": { "Monday": "Пн", From 595f37a27b09db44b3df11e21592fec623a755ea Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 7 Sep 2026 15:13:18 -0700 Subject: [PATCH 11/11] fix(i18n): address review findings on weather localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted `modules/alert_format.py` as the single NWS alert formatter, replacing four copies of the event-type abbreviation table and two of the time compactor. `!wx alerts` and the proactive `WeatherService` broadcasts now localize from one code path, so a Russian bot no longer answers `!wx alerts` in English while its proactive alerts are Russian. - Stopped leaking translation key paths into mesh broadcasts. `Translator.translate` returns the dotted key when a lookup misses in both the locale and the English fallback, which is right for development but reached the air in production: an unclassifiable NWS title rendered as `⚪Hazardous services.weather_service.event_types.Unknown`, an unmapped WMO code as `services.weather_service.weather_descriptions.4`, and an oddly-cased `wind_speed_unit` as `services.weather_service.wind_speed_units.KMH`. `alert_format.translate_or()` carries an English default at each site, and `WeatherService` now normalizes and validates its three `[Weather]` unit settings the way `GlobalWxCommand` already did. - Fixed alert expiry rendering in every locale. The formatter rendered a timestamp to a string and re-parsed its own output with `(\d+)(AM|PM)` against a hardcoded English month list, so translated months took the wrong branch and truncated mid-string. Times now carry parsed parts and render through a per-locale `common.alerts.time_12h` template — the space before AM/PM was correct (Russian writes "6 дня", not "6дня"); the downstream regex was the bug. - Restored month abbreviation. `_compact_time` iterated over abbreviations and replaced them in the string instead of mapping full names, so English stopped shortening "June 28" and Russian replaced the "Jun" inside "June", leaving a stray Latin "e" (`июнe 28`). Reuses the existing `common.date_time.month_abbreviations` rather than the duplicate `services.weather_service.months` block. - Made `!gwx` display units follow `[Weather]` config instead of the response language. Visibility switched on `base_language != 'en'`, so `language = ru` with the default `temperature_unit = fahrenheit` printed Fahrenheit beside kilometers, and `en-GB` was forced to miles. Pressure is a locale convention rather than a metric/imperial split, so each catalog names its own via `commands.gwx.pressure_unit` — previously every non-English locale inherited mmHg from the English catalog, whose `pressure_mmhg` string contained Russian text, giving German and French users Cyrillic pressure units. - Let localized `H`/`L` labels reach a standard install. `config.ini.example` shipped the three `temperature_*_format` keys uncommented with literal `H:`/`L:`, and a config value always beats the new locale-aware default, so a Russian bot built from the documented example still rendered `H:47°C L:33°C`. The example now uses the `{high_label}`/`{low_label}` placeholders, which were documented in the docstring but not in the file. - Routed high/low labels through the reply's translator. `_format_high_low` passed `bot.translator`, so with `auto_detect_language` on, an English-default bot answering a Russian sender localized the rest of the line but not `H:`/`L:`. Added `BaseCommand.response_translator` for this, replacing `wx_international`'s reach into the private `_response_translator` ContextVar. - Fixed a byte-budget overrun in `!gwx`. The guard on the extra conditions block compared a character count against a byte-derived budget while the rest of the function used `_count_display_width`; Cyrillic is two bytes per character, so the block was appended after the budget was spent. - Reverted nine `commands.gwx` English rewordings that were not localization work, including the configuration hint in `mqtt_weather_no_subscriber` — dropping it left a mis-configured operator with no pointer to the two keys they need. - Fixed the Russian `visibility` string, which said "км" on the miles key — the same locale/config conflation as the code bug, in the data. Shortened the Russian event-type abbreviations, which were full words consuming a quarter of the 130-byte budget at two bytes per character. - Added `commands.wx.hourly_not_available`, missing from every catalog so `!wx hourly` printed the raw key path. Predates this branch; found while auditing every translation key the weather modules reference. - Moved alert strings to `common.alerts.*` and wind directions to `common.wind_directions.*`, since a command and a service both read them. --- CHANGELOG.md | 94 ++++ config.ini.example | 13 +- modules/alert_format.py | 511 +++++++++++++++++ .../commands/alternatives/wx_international.py | 58 +- modules/commands/base_command.py | 18 +- modules/commands/wx_command.py | 393 ++----------- modules/service_plugins/weather_service.py | 283 ++-------- pyproject.toml | 1 + tests/unit/test_alert_format.py | 258 +++++++++ tests/unit/test_gwx_display_units.py | 94 ++++ .../unit/test_translation_catalog_hygiene.py | 111 ++++ .../unit/test_weather_service_localization.py | 141 +++++ translations/en.json | 149 +++-- translations/ru.json | 527 ++++++++++++------ 14 files changed, 1792 insertions(+), 859 deletions(-) create mode 100644 modules/alert_format.py create mode 100644 tests/unit/test_alert_format.py create mode 100644 tests/unit/test_gwx_display_units.py create mode 100644 tests/unit/test_translation_catalog_hygiene.py create mode 100644 tests/unit/test_weather_service_localization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e67822..319a526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,102 @@ semantic versioning. locale-aware (`H`/`L` → `В`/`Н` in Russian; `WNW` → `ЗСЗ`). Passed from `WeatherService`, `!wx`, and `!gwx` callers. +- `modules/alert_format.py` holds one NWS alert formatter, shared by `!wx alerts` + and the proactive `WeatherService` broadcasts. Both now localize from the same + code path, so a Russian bot no longer answers `!wx alerts` in English while its + proactive alerts are Russian. It replaces four copies of the event-type + abbreviation table and two of the time compactor, which had already drifted + apart. Alert strings moved to `common.alerts.*` and wind directions to + `common.wind_directions.*`, since a command and a service both read them. + +- `BaseCommand.response_translator` exposes the per-message translator that + `respond_in_sender_language` binds. Command helpers that format part of a reply + need it so one line does not come back in the sender's language and the next in + the bot's default; `wx_international` was reaching for the private + `_response_translator` ContextVar to do this. + ### Fixed +- Weather output no longer leaks translation key paths into mesh broadcasts. The + localization pass replaced several `dict.get(key, fallback)` lookups with bare + `translate()` calls, and `Translator.translate` returns the dotted key path when + a key is missing from both the locale and the English fallback — deliberate, so + missing translations are visible in development, but it reaches the air in + production. An NWS title we cannot classify (`event_type = "Unknown"`, e.g. + "Hazardous Weather Outlook") rendered as + `⚪Hazardous services.weather_service.event_types.Unknown`; an unmapped WMO code + rendered as `services.weather_service.weather_descriptions.4`; and a + `wind_speed_unit` with unexpected casing rendered as + `services.weather_service.wind_speed_units.KMH`. `alert_format.translate_or()` + now carries an English default for each, and `WeatherService` normalizes and + validates its three `[Weather]` unit settings the way `GlobalWxCommand` already + did. + +- Alert expiry times render correctly in every locale. `_format_alert_compact` + formatted a timestamp to a string and then re-parsed it with `(\d+)(AM|PM)` and a + hardcoded English month list, so any locale with translated months took the wrong + branch and then failed the regex, truncating mid-string: + `🟠Flood Warning King до июн 28 6 дн от NWS SEA`. Adding a space before AM/PM — + needed because Russian writes "6 дня", not "6дня" — broke the same regex for + English too, spending 8 characters of a 130-byte budget on a redundant date and + pushing the shortened URL out. Times are now carried as parsed parts and rendered + through a per-locale `common.alerts.time_12h` template, so nothing re-parses + localized output. + +- Month names are abbreviated again, and no longer corrupted. The rewritten + `_compact_time` iterated over month *abbreviations* and replaced them in the + string rather than mapping full names to abbreviations, so English stopped + shortening ("June 28" stayed long) and Russian replaced the "Jun" inside "June", + leaving a stray Latin "e": `июнe 28`. The full-name mapping is restored, reusing + the existing `common.date_time.month_abbreviations` instead of the duplicate + `services.weather_service.months` block the pass had added. + +- `!gwx` display units follow the `[Weather]` unit config instead of the response + language. Visibility and pressure were switched on `base_language != 'en'`, so a + bot with `language = ru` and the default `temperature_unit = fahrenheit` printed + Fahrenheit temperatures beside kilometers, and `en-GB` was forced to miles. Which + pressure unit reads as normal is a locale convention rather than a + metric/imperial split, so each catalog now names its own via + `commands.gwx.pressure_unit` (`mmhg` for `ru`, `hpa` elsewhere) — previously + every non-English locale inherited mmHg from the English catalog, whose + `pressure_mmhg` string contained Russian text ("мм рт. ст."), giving German and + French users Cyrillic pressure units. The Russian `visibility` string, which the + imperial branch uses, said "км" and now says "миль". + +- Localized `H`/`L` temperature labels reach a standard install. `config.ini.example` + shipped `temperature_high_low_format` and its two siblings uncommented with + literal `H:`/`L:`, and a config value always beats the new locale-aware default — + so a Russian bot built from the documented example still rendered `H:47°C L:33°C`. + The example now uses the `{high_label}`/`{low_label}` placeholders, which were + documented in the function docstring but not in the file, and hardcoding `H:`/`L:` + remains available for operators who want English labels regardless of language. + +- `!gwx` high/low labels follow the reply's language. `_format_high_low` passed + `bot.translator` rather than the per-message translator, so with + `auto_detect_language` on, an English-default bot answering a Russian sender + localized the rest of the line but not `H:`/`L:`. The same call in `wx_command` + is fixed alongside it. + +- `!gwx` no longer overruns the RF byte limit on multi-byte locales. The check + guarding the extra conditions block compared a character count against a budget + derived from bytes, while the rest of the function used `_count_display_width` + (UTF-8 bytes). Cyrillic is two bytes per character, so the check saw roughly half + the real size and appended the block after the budget was already spent. + +- `!wx hourly` no longer answers `commands.wx.hourly_not_available` when NOAA + returns no hourly periods. The key was never in any catalog, so the raw key + path reached the user; predates this branch, found while auditing every + translation key the weather modules reference. + +- Restored nine `commands.gwx` English strings that the localization pass reworded + for no functional reason, including the configuration hint in + `mqtt_weather_no_subscriber` — "MQTT weather subscriber is not active (enable + [MqttWeather] and custom.mqtt_weather.* topics)" had become "MQTT weather + subscriber not active", dropping the only pointer to the two keys a + mis-configured operator needs. The rewordings had also diverged from the + identical `commands.wx.*` strings and from the nine other catalogs still carrying + the old English as their fallback text. + - `path` no longer answers "No path information available in current message" on a busy mesh (#255). Verifying a channel message against the RF cache only ever checked the newest row, which assumes the RF log row and the decoded CHAN event diff --git a/config.ini.example b/config.ini.example index 95c759d..0dc9072 100644 --- a/config.ini.example +++ b/config.ini.example @@ -806,11 +806,18 @@ wind_speed_unit = mph # Default: inch precipitation_unit = inch +# Note: gwx shows visibility in km when temperature_unit = celsius, miles +# otherwise. The pressure unit follows the language instead (mmHg for ru, +# hPa elsewhere) since that is a locale convention, not a metric/imperial split. + # How to show daily high/low temperatures (wx, gwx, Weather_Service). # Placeholders: {high} {low} {units} ({units} is °F or °C) -temperature_high_low_format = H:{high}{units} L:{low}{units} -temperature_high_only_format = H:{high}{units} -temperature_low_only_format = L:{low}{units} +# {high_label} {low_label} (localized H/L, per [Localization] language) +# Keep {high_label}/{low_label} to follow the configured language; hardcode +# H:/L: only if you want English labels regardless of language. +temperature_high_low_format = {high_label}:{high}{units} {low_label}:{low}{units} +temperature_high_only_format = {high_label}:{high}{units} +temperature_low_only_format = {low_label}:{low}{units} # Examples: # temperature_high_low_format = ↓{low}°↑{high}{units} # temperature_high_low_format = H:{high}{units} L:{low}{units} diff --git a/modules/alert_format.py b/modules/alert_format.py new file mode 100644 index 0000000..a6ef56f --- /dev/null +++ b/modules/alert_format.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +Shared formatting for NWS weather alerts +Used by both !wx alerts (WxCommand) and the proactive WeatherService broadcasts +""" + +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Optional + +# Month keys are the English abbreviations used throughout the catalogs; the +# translated value comes from common.date_time.month_abbreviations. +MONTH_KEYS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + +FULL_MONTH_KEYS = ("January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December") + +SEVERITY_EMOJI = { + 'Extreme': '🔴', + 'Severe': '🟠', + 'Moderate': '🟡', + 'Minor': '⚪', + 'Unknown': '⚪', +} + +# English fallbacks for the four event types NWS actually publishes. Anything +# else (NWS also emits titles we classify as "Unknown") falls back to the raw +# event_type rather than a translation key. +EVENT_TYPE_ABBREV = { + 'Warning': 'Warn', + 'Watch': 'Watch', + 'Advisory': 'Adv', + 'Statement': 'Stmt', +} + +CITY_ABBREV = { + "Seattle": "SEA", "Portland": "PDX", "San Francisco": "SF", + "Los Angeles": "LA", "New York": "NYC", "Chicago": "CHI", + "Houston": "HOU", "Phoenix": "PHX", "Philadelphia": "PHL", + "San Antonio": "SAT", "San Diego": "SAN", "Dallas": "DAL", + "San Jose": "SJC", "Austin": "AUS", "Jacksonville": "JAX", + "Columbus": "CMH", "Fort Worth": "FTW", "Charlotte": "CLT", + "Denver": "DEN", "Washington": "DC", "Boston": "BOS", + "El Paso": "ELP", "Detroit": "DTW", "Nashville": "BNA", + "Oklahoma City": "OKC", "Las Vegas": "LAS", "Memphis": "MEM", + "Louisville": "SDF", "Baltimore": "BWI", "Milwaukee": "MKE", + "Albuquerque": "ABQ", "Tucson": "TUS", "Fresno": "FAT", + "Sacramento": "SAC", "Kansas City": "KC", "Mesa": "MSC", + "Atlanta": "ATL", "Omaha": "OMA", "Colorado Springs": "COS", + "Raleigh": "RDU", "Virginia Beach": "ORF", "Miami": "MIA", + "Oakland": "OAK", "Minneapolis": "MSP", "Tulsa": "TUL", + "Cleveland": "CLE", "Wichita": "ICT", "Arlington": "ARL", + "Tampa": "TPA", "New Orleans": "MSY", "Honolulu": "HNL", + "Anchorage": "ANC", "Bellingham": "BLI", "Everett": "EVE", + "Spokane": "GEG", "Tacoma": "TAC", "Yakima": "YKM", + "Olympia": "OLM", "Vancouver": "YVR", "Victoria": "YYJ", +} + + +def translate_or(translator: Any, key: str, default: str, **kwargs: Any) -> str: + """Translate ``key``, falling back to ``default`` when it is not in any catalog. + + ``Translator.translate`` deliberately echoes the key back when a lookup + misses in both the requested locale and the English fallback, which makes + missing translations visible in development but leaks a dotted key path + into a mesh broadcast in production. Callers with a sensible English + default should use this instead. + + Args: + translator: Object with a ``translate(key, **kwargs)`` method, or None. + key: Dot-separated key path. + default: Value to use when the key is absent. + **kwargs: Formatting parameters for the translated string. + + Returns: + str: Translated string, or ``default`` formatted with ``kwargs``. + """ + if translator is not None: + value = translator.translate(key, **kwargs) + if value != key: + return value + if kwargs: + try: + return default.format(**kwargs) + except (KeyError, ValueError, IndexError): + return default + return default + + +def severity_emoji(severity: str) -> str: + """Return the colored dot for an alert severity. + + Args: + severity: NWS severity ('Extreme', 'Severe', 'Moderate', 'Minor', ...). + + Returns: + str: Emoji for the severity, defaulting to the 'Unknown' dot. + """ + return SEVERITY_EMOJI.get(severity, '⚪') + + +def event_type_abbrev(event_type: str, translator: Any = None) -> str: + """Abbreviate an alert event type ('Warning' -> 'Warn'). + + Args: + event_type: NWS event type, or any string our title parser produced. + translator: Optional translator for localized abbreviations. + + Returns: + str: Localized abbreviation, or ``event_type`` unchanged when we have + no abbreviation for it. + """ + if not event_type: + return "" + return translate_or( + translator, + f'common.alerts.event_types.{event_type}', + EVENT_TYPE_ABBREV.get(event_type, event_type), + ) + + +def abbreviate_city_name(city: str) -> str: + """Abbreviate a city name for compact display (Seattle -> SEA). + + Args: + city: City name from an NWS office string. + + Returns: + str: Known abbreviation, else the initials of the first three words, + else the first four characters upper-cased. + """ + if not city: + return city + + if city in CITY_ABBREV: + return CITY_ABBREV[city] + + # Partial match handles "Seattle WA" -> "SEA" + for full_name, abbrev in CITY_ABBREV.items(): + if full_name in city: + return abbrev + + words = city.split() + if len(words) > 1: + initials = ''.join(word[0].upper() for word in words[:3]) + if len(initials) <= 4: + return initials + + return city[:4].upper() if len(city) >= 4 else city.upper() + + +@dataclass(frozen=True) +class AlertTime: + """A parsed alert timestamp with its locale-specific renderings. + + Holding the parts rather than a formatted string is what lets callers pick + between the time-only and dated forms without re-parsing localized output + with English-shaped regexes. + """ + + month: str + day: int + hour_12: int + meridiem: str + time_only: str + dated: str + + +def parse_display_time(time_str: str, translator: Any = None) -> Optional[AlertTime]: + """Parse an ISO-8601 alert timestamp into its localized parts. + + Args: + time_str: Timestamp from an NWS alert (e.g. '2025-12-17T01:00:00-08:00'). + translator: Optional translator for month names and AM/PM. + + Returns: + Optional[AlertTime]: Parsed parts, or None when ``time_str`` is not ISO + format or cannot be parsed. + """ + if not time_str or 'T' not in time_str or not re.match(r'\d{4}-\d{2}-\d{2}T', time_str): + return None + + try: + dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) + except ValueError: + # NWS occasionally emits a truncated timestamp ('2025-12-17T01:0'). + try: + date_part, _, clock_part = time_str.partition('T') + clock_part = re.split(r'[-+]', clock_part)[0] + dt = datetime.fromisoformat(f"{date_part}T{clock_part}") + except (ValueError, IndexError): + return None + + month_key = MONTH_KEYS[dt.month - 1] + month = translate_or(translator, f'common.date_time.month_abbreviations.{month_key}', month_key) + + hour = dt.hour + if hour == 0: + hour_12, meridiem_key, meridiem_default = 12, 'am', 'AM' + elif hour < 12: + hour_12, meridiem_key, meridiem_default = hour, 'am', 'AM' + elif hour == 12: + hour_12, meridiem_key, meridiem_default = 12, 'pm', 'PM' + else: + hour_12, meridiem_key, meridiem_default = hour - 12, 'pm', 'PM' + + meridiem = translate_or(translator, f'common.alerts.{meridiem_key}', meridiem_default) + + # Russian writes "6 дня" where English writes "6PM", so the separator is + # the locale's business, not ours. + time_only = translate_or( + translator, 'common.alerts.time_12h', '{hour}{meridiem}', + hour=hour_12, meridiem=meridiem, + ) + dated = translate_or( + translator, 'common.alerts.date_12h', '{month} {day} {time}', + month=month, day=dt.day, time=time_only, + ) + return AlertTime(month=month, day=dt.day, hour_12=hour_12, meridiem=meridiem, + time_only=time_only, dated=dated) + + +def compact_time(time_str: str, translator: Any = None) -> str: + """Shorten an alert timestamp for a mesh message. + + ISO timestamps become the localized dated form ('Dec 17 1AM'). Free-text + NWS strings ('December 16 at 3:12PM') get their month abbreviated and their + ':00' minutes and filler 'at' dropped. + + Args: + time_str: Timestamp or free-text time from an NWS alert. + translator: Optional translator for month names and AM/PM. + + Returns: + str: Compacted time string, or ``time_str`` unchanged when empty. + """ + if not time_str: + return time_str + + parsed = parse_display_time(time_str, translator) + if parsed is not None: + return parsed.dated + + # Remove leading zeros from hours: "6:00AM" -> "6AM" + time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str) + + # Abbreviate month names. Longest first so "June" is not matched by "Jun". + for full_key, abbrev_key in zip(FULL_MONTH_KEYS, MONTH_KEYS, strict=True): + if full_key not in time_str: + continue + abbrev = translate_or(translator, f'common.date_time.month_abbreviations.{abbrev_key}', abbrev_key) + time_str = time_str.replace(full_key, abbrev) + + # Remove "at" before time: "December 16 at 3:12PM" -> "Dec 16 3:12PM" + time_str = re.sub(r'\s+at\s+', ' ', time_str) + + # NWS writes these titles in English ("until December 17 at 6:00AM PST"), + # so localize the meridiem the same way the ISO path does. + def _localize_meridiem(match: "re.Match[str]") -> str: + meridiem = translate_or( + translator, f'common.alerts.{match.group(2).lower()}', match.group(2).upper() + ) + return translate_or( + translator, 'common.alerts.time_12h', '{hour}{meridiem}', + hour=match.group(1), meridiem=meridiem, + ) + + time_str = re.sub(r'(\d+(?::\d+)?)\s*(AM|PM)\b', _localize_meridiem, time_str, + flags=re.IGNORECASE) + + return time_str + + +def shorten_event(event: str, limit: Optional[int] = 15, max_words: int = 2) -> str: + """Trim a long event name to its leading words. + + Args: + event: NWS event name (e.g. 'High Wind Warning'). + limit: Length above which the name is trimmed, or None to never trim. + max_words: Number of leading words to keep when trimming. + + Returns: + str: Shortened event name. + """ + if limit is None or len(event) <= limit: + return event + words = event.split() + if len(words) > max_words: + return ' '.join(words[:max_words]) + return event[:limit] + + +def format_event_label(event: str, event_type: str, translator: Any = None, + limit: Optional[int] = 15, max_words: int = 2) -> str: + """Render ' ', dropping the type when it is redundant. + + Args: + event: NWS event name (e.g. 'High Wind Warning'). + event_type: NWS event type (e.g. 'Warning'). + translator: Optional translator for the type abbreviation. + limit: Length above which the event name is trimmed, or None to never trim. + max_words: Number of leading words to keep when trimming. + + Returns: + str: Combined label, or just the abbreviation when ``event`` is empty. + """ + abbrev = event_type_abbrev(event_type, translator) + if not event: + return abbrev + short = shorten_event(event, limit, max_words) + if event_type and event_type.lower() in event.lower(): + # "High Wind Warning" already says "Warning" + return short + return f"{short} {abbrev}" if abbrev else short + + +def format_event_plain(event: str, event_type: str, translator: Any = None) -> str: + """Render ' ' with no trimming and no redundancy check. + + This is the fallback form used when a message has already overrun its + budget and the caller is retrying with less detail. + + Args: + event: NWS event name. + event_type: NWS event type. + translator: Optional translator for the type abbreviation. + + Returns: + str: Combined label, or just the abbreviation when ``event`` is empty. + """ + abbrev = event_type_abbrev(event_type, translator) + return f"{event} {abbrev}" if event else abbrev + + +def first_location(area_desc: str, limit: int = 20) -> str: + """Extract one short place name from an NWS area description. + + Args: + area_desc: Semicolon-separated areas ('King County; Snohomish County'). + limit: Maximum length of the returned name. + + Returns: + str: Short place name, or '' when ``area_desc`` is empty. + """ + if not area_desc: + return "" + + first = area_desc.split(';')[0].strip() + if ',' in first: + # "Seattle, WA" -> "Seattle" + location = first.split(',')[0].strip() + else: + words = first.split() + if len(words) > 1 and words[-1].lower() in ('county', 'parish', 'borough'): + location = words[0] + else: + location = first + return location[:limit] + + +def format_office(office: str, translator: Any = None, limit: int = 10) -> str: + """Render the issuing office compactly ('NWS Seattle WA' -> 'by NWS SEA'). + + Args: + office: Office string from the alert. + translator: Optional translator for the 'by' label. + limit: Length to truncate a single-token office to. + + Returns: + str: Formatted office attribution, or '' when ``office`` is empty. + """ + if not office: + return "" + by_label = translate_or(translator, 'common.alerts.by', 'by') + parts = office.split() + if len(parts) >= 2: + return f"{by_label} {parts[0]} {abbreviate_city_name(parts[1])}" + return f"{by_label} {office[:limit]}" + + +def format_alert_compact(alert: dict[str, Any], translator: Any = None, + include_details: bool = True, + include_location: bool = True) -> str: + """Format one alert for a mesh message. + + Produces "🟠High Wind Warn King til 6AM by NWS SEA" (details) or + "🟠High Wind Warn" (summary). The caller is responsible for appending a + shortened link, which needs async work. + + Args: + alert: Alert dict with event, event_type, severity, expires, office, + and optionally area_desc. + translator: Optional translator for all labels. + include_details: If True, include location, expiry and office. + include_location: If True (and ``include_details``), include the area. + + Returns: + str: Formatted alert string. + """ + event = alert.get('event', '') + event_type = alert.get('event_type', '') + severity = alert.get('severity', 'Unknown') + emoji = severity_emoji(severity) + + if not include_details: + return emoji + format_event_plain(event, event_type, translator) + + result = emoji + format_event_label(event, event_type, translator) + + if include_location: + location = first_location(alert.get('area_desc', '')) + if location: + result += f" {location}" + + expires = alert.get('expires', '') + if expires: + til_label = translate_or(translator, 'common.alerts.til', 'til') + result += f" {til_label} {_expiry_label(expires, translator)}" + + office = format_office(alert.get('office', ''), translator) + if office: + result += f" {office}" + + return result + + +def extract_clock(time_str: str, translator: Any = None) -> Optional[str]: + """Pull just the clock time out of a free-text NWS timestamp. + + Args: + time_str: Free-text time ('December 17 at 6:00AM PST'). + translator: Optional translator for AM/PM. + + Returns: + Optional[str]: Localized clock time ('6AM', '6 утра'), or None when the + string carries no 12-hour clock. + """ + match = re.search(r'(\d+)(?::(\d+))?\s*(AM|PM)\b', time_str, re.IGNORECASE) + if match is None: + return None + hour, minutes, meridiem_raw = match.group(1), match.group(2), match.group(3).upper() + # ":00" reads as noise in a message this tight. + hour_text = f"{hour}:{minutes}" if minutes and minutes != '00' else hour + meridiem = translate_or(translator, f'common.alerts.{meridiem_raw.lower()}', meridiem_raw) + return translate_or(translator, 'common.alerts.time_12h', '{hour}{meridiem}', + hour=hour_text, meridiem=meridiem) + + +def _expiry_label(expires: str, translator: Any = None) -> str: + """Render an expiry timestamp as compactly as it can be read. + + Args: + expires: Expiry timestamp from the alert. + translator: Optional translator for month names and AM/PM. + + Returns: + str: The clock time alone where we can find one, else a truncated + compact form. + """ + parsed = parse_display_time(expires, translator) + if parsed is not None: + # A dated expiry costs ~8 chars of a 130-byte budget; the time alone is + # unambiguous for alerts that expire within a day. + return parsed.time_only + + # Same reasoning for the free-text form NWS puts in ATOM titles. Read the + # clock off the English source rather than re-parsing localized output. + clock = extract_clock(expires, translator) + if clock is not None: + return clock + return compact_time(expires, translator)[:15] + + +def format_alert_window(alert: dict[str, Any], translator: Any = None) -> str: + """Render an alert's effective/expiry window ('from Dec 16 3PM til Dec 17 6AM'). + + Args: + alert: Alert dict with optional effective and expires timestamps. + translator: Optional translator for labels. + + Returns: + str: Formatted window, or '' when the alert carries no timestamps. + """ + parts = [] + effective = alert.get('effective', '') + if effective: + from_label = translate_or(translator, 'common.alerts.from', 'from') + parts.append(f"{from_label} {_window_label(effective, translator)}") + expires = alert.get('expires', '') + if expires: + til_label = translate_or(translator, 'common.alerts.til', 'til') + parts.append(f"{til_label} {_window_label(expires, translator)}") + return " ".join(parts) + + +def _window_label(time_str: str, translator: Any = None) -> str: + """Render a timestamp for the dated window form. + + Args: + time_str: Timestamp from the alert. + translator: Optional translator for month names and AM/PM. + + Returns: + str: Dated form for ISO timestamps, else a truncated compact form. + """ + parsed = parse_display_time(time_str, translator) + if parsed is not None: + return parsed.dated + return compact_time(time_str, translator)[:25] diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index a1bed7e..c6a51dd 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -19,7 +19,7 @@ from ...utils import ( get_nominatim_geocoder, rate_limited_nominatim_reverse_sync, ) -from ..base_command import BaseCommand, _response_translator +from ..base_command import BaseCommand # Import WXSIM parser for custom weather sources try: @@ -38,6 +38,12 @@ from ...clients.mqtt_weather import ( # Multiday: plain digits, 7day/7-day, or suffix form 7d/10d (min 2, max below). Open-Meteo allows up to 16 forecast days. GWX_MULTIDAY_MAX_DAYS = 16 +MI_TO_KM = 1.609344 +HPA_TO_MMHG = 0.750062 +# Past ~20 mi / 32 km, visibility is reported as unlimited anyway. +VISIBILITY_CAP_MI = 20 +VISIBILITY_CAP_KM = 32 + class GlobalWxCommand(BaseCommand): """Handles global weather commands with city/location support""" @@ -108,10 +114,21 @@ class GlobalWxCommand(BaseCommand): # Get database manager for geocoding cache self.db_manager = bot.db_manager + @property + def metric_distance(self) -> bool: + """Whether distances should be shown in kilometers. + + Derived from [Weather] temperature_unit rather than the response + language so every unit in one reply agrees: a bot configured for + Fahrenheit should not print kilometers just because it answers in + Russian. + """ + return self.temperature_unit == 'celsius' + def _format_high_low(self, high: Optional[Union[int, float]], low: Optional[Union[int, float]], temp_symbol: str) -> str: """Format high/low using [Weather] temperature_*_format templates.""" return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger, - translator=getattr(self.bot, 'translator', None)) + translator=self.response_translator) def _load_weather_model(self) -> Optional[str]: """Load and normalize Open-Meteo model selection from config. @@ -1080,34 +1097,25 @@ class GlobalWxCommand(BaseCommand): # Add visibility (already converted to miles above) if visibility_mi is not None and visibility_mi > 0: - # Determine if the response locale uses metric (km) vs imperial (mi) - translator = _response_translator.get() or getattr(self.bot, 'translator', None) - base_lang = getattr(translator, 'base_language', 'en') or 'en' - metric = base_lang != 'en' - if metric: - # Convert miles to kilometers and cap at ~32 km (equivalent to 20 mi) - visibility_km = visibility_mi * 1.609344 - visibility_display = int(visibility_km) - if visibility_display > 32: - visibility_display = 32 + # Beyond ~20 mi visibility is essentially unlimited, so cap the + # display at that in whichever unit we are showing. + if self.metric_distance: + visibility_display = min(int(visibility_mi * MI_TO_KM), VISIBILITY_CAP_KM) vis_str = self.translate('commands.gwx.visibility_km', value=visibility_display) else: - # Cap visibility at 20 miles for display (beyond that is essentially unlimited) - visibility_display = int(visibility_mi) - if visibility_display > 20: - visibility_display = 20 + visibility_display = min(int(visibility_mi), VISIBILITY_CAP_MI) vis_str = self.translate('commands.gwx.visibility', value=visibility_display) conditions.append(vis_str) # Add pressure (convert from hPa to display format) if pressure is not None: pressure_hpa = int(pressure) - # Use mmHg for metric (non-English) locales; hPa for imperial/English - translator = _response_translator.get() or getattr(self.bot, 'translator', None) - base_lang = getattr(translator, 'base_language', 'en') or 'en' - if base_lang != 'en': - pressure_mmhg = round(pressure_hpa * 0.750062) - press_str = self.translate('commands.gwx.pressure_mmhg', value=pressure_mmhg) + # Which pressure unit reads as normal is a locale convention, not + # a metric/imperial split: Russia uses mmHg, most of metric + # Europe uses hPa. The catalog names its own. + if self.translate('commands.gwx.pressure_unit').strip().lower() == 'mmhg': + press_str = self.translate('commands.gwx.pressure_mmhg', + value=round(pressure_hpa * HPA_TO_MMHG)) else: press_str = self.translate('commands.gwx.pressure', value=pressure_hpa) conditions.append(press_str) @@ -1115,7 +1123,7 @@ class GlobalWxCommand(BaseCommand): # Add conditions to weather string if space allows # Reserve space for forecast data (high/low and tomorrow) conditions_max_length = max_length - 80 # Reserve ~80 chars for forecast data - if conditions and len(weather) < conditions_max_length: + if conditions and self._count_display_width(weather) < conditions_max_length: weather += " " + " ".join(conditions) # Add forecast high/low for today (without repeating period name since current conditions already show it) @@ -1398,11 +1406,11 @@ class GlobalWxCommand(BaseCommand): for i in range(len(dir_emojis) - 1): if dir_emojis[i][0] <= degrees < dir_emojis[i + 1][0]: emoji, key = dir_emojis[i][1], dir_emojis[i][2] - translated = self.translate(f"services.weather_service.wind_directions.{key}") + translated = self.translate(f"common.wind_directions.{key}") return f"{emoji}{translated}" emoji, key = dir_emojis[-1][1], dir_emojis[-1][2] - translated = self.translate(f"services.weather_service.wind_directions.{key}") + translated = self.translate(f"common.wind_directions.{key}") return f"{emoji}{translated}" def _get_weather_description(self, code: int) -> str: diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index adb5a61..563c7ad 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -101,6 +101,20 @@ class BaseCommand(ABC): # Load translated keywords after initialization self._load_translated_keywords() + @property + def response_translator(self) -> Any: + """The translator for the reply being built, or the bot default. + + ``respond_in_sender_language`` binds a per-message translator for the + duration of one reply, so helpers that format part of a response need + this rather than ``bot.translator`` — otherwise one line of a reply + comes back in the sender's language and the next in the bot's default. + + Returns: + Any: Translator object, or None when the bot has none. + """ + return _response_translator.get() or getattr(self.bot, 'translator', None) + def translate(self, key: str, **kwargs: Any) -> str: """Translate a key using the bot's translator. @@ -111,7 +125,7 @@ class BaseCommand(ABC): Returns: str: Translated string, or key if translation not found. """ - translator = _response_translator.get() or getattr(self.bot, 'translator', None) + translator = self.response_translator if translator is not None: return translator.translate(key, **kwargs) # Fallback if translator not available @@ -126,7 +140,7 @@ class BaseCommand(ABC): Returns: Any: The value at the key path, or None if not found. """ - translator = _response_translator.get() or getattr(self.bot, 'translator', None) + translator = self.response_translator if translator is not None: return translator.get_value(key) return None diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 29eab12..f824980 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -15,6 +15,7 @@ import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +from .. import alert_format from ..models import MeshMessage from ..utils import ( format_temperature_high_low, @@ -178,7 +179,7 @@ class WxCommand(BaseCommand): def _format_high_low(self, high: Optional[float], low: Optional[float], temp_symbol: str) -> str: """Format high/low using [Weather] temperature_*_format templates.""" return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger, - translator=getattr(self.bot, 'translator', None)) + translator=self.response_translator) @staticmethod def _noaa_period_temp_symbol(period: dict) -> str: @@ -2635,120 +2636,24 @@ class WxCommand(BaseCommand): def _format_alert_compact(self, alert: dict, include_details: bool = True) -> str: """Format a single alert compactly + Shares its formatting with the proactive WeatherService broadcasts via + ``modules.alert_format``, so both localize from one code path. + Args: alert: Alert dict with event, event_type, severity, expires, office, etc. include_details: If True, include expiration time and office Returns: - Formatted alert string + Formatted alert string, e.g. "🟠High Wind Warn til 6AM by NWS SEA" """ - event = alert.get('event', '') - event_type = alert.get('event_type', '') - severity = alert.get('severity', 'Unknown') - expires = alert.get('expires', '') - office = alert.get('office', '') - - # Get severity emoji - severity_emoji = { - 'Extreme': '🔴', - 'Severe': '🟠', - 'Moderate': '🟡', - 'Minor': '⚪', - 'Unknown': '⚪' - }.get(severity, '⚪') - - # Get event type emoji/indicator - { - 'Warning': '⚠️', - 'Watch': '👁️', - 'Advisory': 'ℹ️', - 'Statement': '📢' - }.get(event_type, '') - - # Format event type abbreviation - event_type_abbrev = { - 'Warning': 'Warn', - 'Watch': 'Watch', - 'Advisory': 'Adv', - 'Statement': 'Stmt' - }.get(event_type, event_type) - - # Build compact alert string - if include_details: - # Full format: "🟠High Wind Warn til 6AM by NWS SEA" - # Start with emoji directly concatenated to text (no space) - result = severity_emoji - - # Add event and type - if event: - # Check if event already contains the event type to avoid duplication - event_lower = event.lower() - event_type_lower = event_type.lower() - if event_type_lower in event_lower: - # Event already contains type (e.g., "High Wind Warning"), just use event - event_short = event - if len(event) > 15: - # Take first words - words = event.split() - event_short = ' '.join(words[:2]) if len(words) > 2 else event[:15] - result += event_short - else: - # Event doesn't contain type, add it - event_short = event - if len(event) > 15: - # Take first words - words = event.split() - event_short = ' '.join(words[:2]) if len(words) > 2 else event[:15] - result += f"{event_short} {event_type_abbrev}" - else: - result += event_type_abbrev - - # Add expiration time if available - if expires: - expires_compact = self.compact_time(expires) - # Extract just the time part - # "Dec 17 1AM" -> "til 1AM" (prefer just time for compactness) - # Check if it's in compact format with month name (from ISO parsing) - if any(month in expires_compact for month in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]): - # Has date, extract just time part for compactness - time_match = re.search(r'(\d+)(AM|PM)', expires_compact, re.IGNORECASE) - if time_match: - hour = time_match.group(1) - am_pm = time_match.group(2) - expires_short = f" til {hour}{am_pm}" - else: - # Fallback: use compact version but limit length - expires_short = f" til {expires_compact[:15]}" - else: - # Try to extract time pattern from other formats - time_match = re.search(r'(\d+):?(\d+)?(AM|PM)', expires_compact, re.IGNORECASE) - if time_match: - hour = time_match.group(1) - am_pm = time_match.group(3) - expires_short = f" til {hour}{am_pm}" - else: - # If no time pattern found, use compact version (truncated) - expires_short = f" til {expires_compact[:15]}" - result += expires_short - - # Add office if available (abbreviate city name) - if office: - # Extract city from office (e.g., "NWS Seattle WA" -> "NWS SEA") - office_parts = office.split() - if len(office_parts) >= 2: - # Assume format: "NWS Seattle WA" or "NWS Seattle" - office_org = office_parts[0] # "NWS" - city = office_parts[1] if len(office_parts) > 1 else "" - city_abbrev = self.abbreviate_city_name(city) - office_short = f" by {office_org} {city_abbrev}" - else: - office_short = f" by {office[:10]}" # Truncate - result += office_short - - return result - else: - # Abbreviated format: just event type and severity - return f"{severity_emoji}{event} {event_type_abbrev}" if event else f"{severity_emoji}{event_type_abbrev}" + return alert_format.format_alert_compact( + alert, + self.response_translator, + include_details=include_details, + # !wx alerts has never shown the area description; the proactive + # broadcast does, so the shared helper keeps it optional. + include_location=False, + ) def _format_alerts_compact_summary(self, alerts: list, alert_count: int, max_length: int = 130) -> str: """Format multiple alerts with prioritized first alert and summary of others @@ -2782,27 +2687,14 @@ class WxCommand(BaseCommand): event = alert.get('event', '') event_type = alert.get('event_type', '') - # Get event type abbreviation - event_type_abbrev = { - 'Warning': 'Warn', - 'Watch': 'Watch', - 'Advisory': 'Adv', - 'Statement': 'Stmt' - }.get(event_type, event_type) - # Get emoji for event type event_emoji = self._get_event_emoji(event, event_type) - # Build compact event string + # Build compact event string. Trimmed harder than the lead alert: + # first word only, since these are a comma-joined tail. + event_short = alert_format.shorten_event(event, limit=12, max_words=1) + event_type_abbrev = alert_format.event_type_abbrev(event_type, self.response_translator) if event: - # Abbreviate long event names - event_short = event - if len(event) > 12: - words = event.split() - if len(words) > 1: - event_short = words[0] # Just first word - else: - event_short = event[:12] remaining_parts.append(f"{event_emoji}{event_short} {event_type_abbrev}") else: remaining_parts.append(f"{event_emoji}{event_type_abbrev}") @@ -2879,96 +2771,28 @@ class WxCommand(BaseCommand): Returns: Formatted alert string with start/stop times """ - event = alert.get('event', '') - event_type = alert.get('event_type', '') - severity = alert.get('severity', 'Unknown') - effective = alert.get('effective', '') - expires = alert.get('expires', '') - office = alert.get('office', '') - - # Get severity emoji - severity_emoji = { - 'Extreme': '🔴', - 'Severe': '🟠', - 'Moderate': '🟡', - 'Minor': '⚪', - 'Unknown': '⚪' - }.get(severity, '⚪') - - # Format event type - event_type_abbrev = { - 'Warning': 'Warn', - 'Watch': 'Watch', - 'Advisory': 'Adv', - 'Statement': 'Stmt' - }.get(event_type, event_type) - - # Build parts + translator = self.response_translator parts = [] - # Add index if provided if index is not None: parts.append(f"{index}.") - # Add severity emoji and event - if event: - # Check if event already contains the event type to avoid duplication - event_lower = event.lower() - event_type_lower = event_type.lower() - if event_type_lower in event_lower: - # Event already contains type (e.g., "High Wind Warning"), just use event - parts.append(f"{severity_emoji}{event}") - else: - # Event doesn't contain type, add it - parts.append(f"{severity_emoji}{event} {event_type_abbrev}") - else: - parts.append(f"{severity_emoji}{event_type_abbrev}") + # No trimming here — this form is sent across as many messages as it needs. + parts.append( + alert_format.severity_emoji(alert.get('severity', 'Unknown')) + + alert_format.format_event_label( + alert.get('event', ''), alert.get('event_type', ''), translator, limit=None + ) + ) - # Add times - time_parts = [] - if effective: - effective_compact = self.compact_time(effective) - # Extract just the essential time info - # Try pattern: "December 16 at 3:12PM" or "Dec 16 3:12PM" - time_match = re.search(r'(\w+\s+\d+)\s+(?:at\s+)?(\d+):?(\d+)?(AM|PM)', effective_compact, re.IGNORECASE) - if time_match: - date_part = time_match.group(1) - hour = time_match.group(2) - am_pm = time_match.group(4) - time_parts.append(f"from {date_part} {hour}{am_pm}") - else: - # Fallback: just use compacted version, truncate if needed - effective_short = effective_compact[:25] - time_parts.append(f"from {effective_short}") + window = alert_format.format_alert_window(alert, translator) + if window: + parts.append(window) - if expires: - expires_compact = self.compact_time(expires) - # Extract time part - # Try pattern: "December 17 at 6:00AM" or "Dec 17 6AM" - time_match = re.search(r'(\w+\s+\d+)\s+(?:at\s+)?(\d+):?(\d+)?(AM|PM)', expires_compact, re.IGNORECASE) - if time_match: - date_part = time_match.group(1) - hour = time_match.group(2) - am_pm = time_match.group(4) - time_parts.append(f"til {date_part} {hour}{am_pm}") - else: - # Fallback: just use compacted version, truncate if needed - expires_short = expires_compact[:25] - time_parts.append(f"til {expires_short}") - - if time_parts: - parts.append(" ".join(time_parts)) - - # Add office (abbreviated) + # The full form is not length-capped, so it keeps the longer fallback. + office = alert_format.format_office(alert.get('office', ''), translator, limit=15) if office: - office_parts = office.split() - if len(office_parts) >= 2: - office_org = office_parts[0] - city = office_parts[1] - city_abbrev = self.abbreviate_city_name(city) - parts.append(f"by {office_org} {city_abbrev}") - else: - parts.append(f"by {office[:15]}") + parts.append(office) return " ".join(parts) @@ -3098,155 +2922,12 @@ class WxCommand(BaseCommand): def abbreviate_city_name(self, city: str) -> str: """Abbreviate city names for compact display (e.g., Seattle -> SEA)""" - if not city: - return city - - # Common city abbreviations - city_abbrevs = { - "Seattle": "SEA", - "Portland": "PDX", - "San Francisco": "SF", - "Los Angeles": "LA", - "New York": "NYC", - "Chicago": "CHI", - "Houston": "HOU", - "Phoenix": "PHX", - "Philadelphia": "PHL", - "San Antonio": "SAT", - "San Diego": "SAN", - "Dallas": "DAL", - "San Jose": "SJC", - "Austin": "AUS", - "Jacksonville": "JAX", - "Columbus": "CMH", - "Fort Worth": "FTW", - "Charlotte": "CLT", - "Denver": "DEN", - "Washington": "DC", - "Boston": "BOS", - "El Paso": "ELP", - "Detroit": "DTW", - "Nashville": "BNA", - "Oklahoma City": "OKC", - "Las Vegas": "LAS", - "Memphis": "MEM", - "Louisville": "SDF", - "Baltimore": "BWI", - "Milwaukee": "MKE", - "Albuquerque": "ABQ", - "Tucson": "TUS", - "Fresno": "FAT", - "Sacramento": "SAC", - "Kansas City": "KC", - "Mesa": "MSC", - "Atlanta": "ATL", - "Omaha": "OMA", - "Colorado Springs": "COS", - "Raleigh": "RDU", - "Virginia Beach": "ORF", - "Miami": "MIA", - "Oakland": "OAK", - "Minneapolis": "MSP", - "Tulsa": "TUL", - "Cleveland": "CLE", - "Wichita": "ICT", - "Arlington": "ARL", - "Tampa": "TPA", - "New Orleans": "MSY", - "Honolulu": "HNL", - "Anchorage": "ANC", - "Bellingham": "BLI", - "Everett": "EVE", - "Spokane": "GEG", - "Tacoma": "TAC", - "Yakima": "YKM", - "Olympia": "OLM", - "Vancouver": "YVR", - "Victoria": "YYJ" - } - - # Check for exact match first - if city in city_abbrevs: - return city_abbrevs[city] - - # Check for partial matches (e.g., "Seattle WA" -> "SEA") - for full_name, abbrev in city_abbrevs.items(): - if full_name in city: - return abbrev - - # If no match, try to create abbreviation from first letters of words - words = city.split() - if len(words) > 1: - # Take first letter of each word, up to 3-4 letters - abbrev = ''.join([w[0].upper() for w in words[:3]]) - if len(abbrev) <= 4: - return abbrev - - # Fallback: return first 3-4 uppercase letters - return city[:4].upper() if len(city) >= 4 else city.upper() + return alert_format.abbreviate_city_name(city) def compact_time(self, time_str: str) -> str: """Compact time format: '6:00AM' -> '6AM', 'December 16 at 3:12PM' -> 'Dec 16 3:12PM' Also handles ISO format: '2025-12-17T01:00:00-08:00' -> 'Dec 17 1AM'""" - if not time_str: - return time_str - - # Check if it's ISO format (contains 'T' and looks like datetime) - if 'T' in time_str and re.match(r'\d{4}-\d{2}-\d{2}T', time_str): - try: - from datetime import datetime - # Parse ISO format - # Handle various ISO formats: 2025-12-17T01:00:00-08:00, 2025-12-17T01:0, etc. - # Try to parse with timezone info first - try: - dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) - except: - # Try without timezone - dt_str = time_str.split('T')[0] + 'T' + time_str.split('T')[1].split('-')[0].split('+')[0] - dt = datetime.fromisoformat(dt_str) - - # Format as "Dec 17 1AM" - month_abbrevs = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - month = month_abbrevs[dt.month - 1] - day = dt.day - hour = dt.hour - - # Convert to 12-hour format - if hour == 0: - hour_12 = 12 - am_pm = "AM" - elif hour < 12: - hour_12 = hour - am_pm = "AM" - elif hour == 12: - hour_12 = 12 - am_pm = "PM" - else: - hour_12 = hour - 12 - am_pm = "PM" - - return f"{month} {day} {hour_12}{am_pm}" - except Exception: - # If parsing fails, fall through to regular processing - pass - - # Remove leading zeros from hours: "6:00AM" -> "6AM", "10:00PM" -> "10PM" - time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str) - - # Abbreviate month names - month_abbrevs = { - "January": "Jan", "February": "Feb", "March": "Mar", "April": "Apr", - "May": "May", "June": "Jun", "July": "Jul", "August": "Aug", - "September": "Sep", "October": "Oct", "November": "Nov", "December": "Dec" - } - for full, abbrev in month_abbrevs.items(): - time_str = time_str.replace(full, abbrev) - - # Remove "at" before time: "December 16 at 3:12PM" -> "December 16 3:12PM" - time_str = re.sub(r'\s+at\s+', ' ', time_str) - - return time_str + return alert_format.compact_time(time_str, self.response_translator) def abbreviate_wind_direction(self, direction: str) -> str: """Abbreviate wind direction to emoji + 2-3 characters""" @@ -3344,7 +3025,7 @@ class WxCommand(BaseCommand): if _pair_ok(high_val, low_val): return format_temperature_high_low( self.bot.config, high_val, low_val, units_str, self.logger, - translator=getattr(self.bot, 'translator', None), + translator=self.response_translator, ) except ValueError: continue @@ -3356,7 +3037,7 @@ class WxCommand(BaseCommand): if _single_ok(low_val): return format_temperature_high_low( self.bot.config, None, low_val, units_str, self.logger, - translator=getattr(self.bot, 'translator', None), + translator=self.response_translator, ) except ValueError: pass @@ -3368,7 +3049,7 @@ class WxCommand(BaseCommand): if _single_ok(high_val): return format_temperature_high_low( self.bot.config, high_val, None, units_str, self.logger, - translator=getattr(self.bot, 'translator', None), + translator=self.response_translator, ) except ValueError: pass diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 2f7bd75..42a8503 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -31,6 +31,7 @@ except ImportError: import contextlib +from .. import alert_format from ..commands.rain_command import ( analyze_precip_nowcast, decide_rain_notification, @@ -131,10 +132,21 @@ class WeatherService(BaseServicePlugin): # serialized without making the event loop wait on a threading lock. self._api_session_lock = threading.Lock() - # Get temperature/wind units from config (for Open-Meteo) - self.temperature_unit = self.bot.config.get('Weather', 'temperature_unit', fallback='fahrenheit') - self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph') - self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch') + # Get temperature/wind units from config (for Open-Meteo). Normalized and + # validated the same way GlobalWxCommand does, so the unit also works as + # a translation key for its display label. + self.temperature_unit = self.bot.config.get('Weather', 'temperature_unit', fallback='fahrenheit').lower() + self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph').lower() + self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch').lower() + if self.temperature_unit not in ('fahrenheit', 'celsius'): + self.logger.warning(f"Invalid temperature_unit '{self.temperature_unit}', using 'fahrenheit'") + self.temperature_unit = 'fahrenheit' + if self.wind_speed_unit not in ('mph', 'kmh', 'ms', 'kn'): + self.logger.warning(f"Invalid wind_speed_unit '{self.wind_speed_unit}', using 'mph'") + self.wind_speed_unit = 'mph' + if self.precipitation_unit not in ('inch', 'mm'): + self.logger.warning(f"Invalid precipitation_unit '{self.precipitation_unit}', using 'inch'") + self.precipitation_unit = 'inch' # Proactive rain nowcast ("rain incoming" push). Reuses the rain command's # Open-Meteo 15-minutely logic for the bot's own position. @@ -642,9 +654,12 @@ class WeatherService(BaseServicePlugin): # Format current forecast forecast_text = f"{location_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}" if wind_speed > 0: - wind_dir_str = f"{wind_direction} " if wind_direction else "" - wind_unit_label = self._translate(f'services.weather_service.wind_speed_units.{self.wind_speed_unit}') - forecast_text += f" {wind_dir_str}{wind_speed}{wind_unit_label}" + wind_unit_label = alert_format.translate_or( + getattr(self.bot, 'translator', None), + f'services.weather_service.wind_speed_units.{self.wind_speed_unit}', + self.wind_speed_unit, + ) + forecast_text += f" {wind_direction}{wind_speed}{wind_unit_label}" today_high = int(daily['temperature_2m_max'][0]) today_low = int(daily['temperature_2m_min'][0]) @@ -716,7 +731,9 @@ class WeatherService(BaseServicePlugin): 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] index = int((degrees + 11.25) / 22.5) % 16 key = directions[index] - return self._translate(f'services.weather_service.wind_directions.{key}') + return alert_format.translate_or( + getattr(self.bot, 'translator', None), f'common.wind_directions.{key}', key + ) def _get_weather_description(self, code: int) -> str: """Get weather description from WMO weather code. @@ -725,9 +742,14 @@ class WeatherService(BaseServicePlugin): code: WMO weather code integer. Returns: - str: Human-readable weather description. + str: Human-readable weather description, or the localized 'Unknown' + for a code we have no description for. """ - return self._translate(f'services.weather_service.weather_descriptions.{code}') + translator = getattr(self.bot, 'translator', None) + unknown = alert_format.translate_or(translator, 'common.unknown', 'Unknown') + return alert_format.translate_or( + translator, f'services.weather_service.weather_descriptions.{code}', unknown + ) def _get_weather_emoji(self, code: int) -> str: """Get weather emoji from WMO weather code. @@ -1607,238 +1629,37 @@ class WeatherService(BaseServicePlugin): return None async def _format_alert_compact(self, alert: dict[str, Any], include_details: bool = True) -> str: - """Format a single alert compactly (same as wx_command). + """Format a single alert compactly, appending a shortened link if it fits. + + Shares its formatting with ``!wx alerts`` via ``modules.alert_format``; + only the link shortening (which needs async work) lives here. Args: alert: Alert dict with event, event_type, severity, expires, office, etc. - include_details: If True, include expiration time and office. + include_details: If True, include location, expiration time and office. Returns: str: Formatted alert string. """ - event = alert.get('event', '') - event_type = alert.get('event_type', '') - severity = alert.get('severity', 'Unknown') - expires = alert.get('expires', '') - office = alert.get('office', '') - link_url = alert.get('link', '') - area_desc = alert.get('area_desc', '') - - # Get severity emoji - severity_emoji = { - 'Extreme': '🔴', - 'Severe': '🟠', - 'Moderate': '🟡', - 'Minor': '⚪', - 'Unknown': '⚪' - }.get(severity, '⚪') - - # Format event type abbreviation - event_type_abbrev = self._translate( - f'services.weather_service.event_types.{event_type}', event_type=event_type + result = alert_format.format_alert_compact( + alert, getattr(self.bot, 'translator', None), include_details=include_details ) - - # Build compact alert string - if include_details: - result = severity_emoji - - # Add event and type - if event: - event_lower = event.lower() - event_type_lower = event_type.lower() - if event_type_lower in event_lower: - event_short = event - if len(event) > 15: - words = event.split() - event_short = ' '.join(words[:2]) if len(words) > 2 else event[:15] - result += event_short - else: - event_short = event - if len(event) > 15: - words = event.split() - event_short = ' '.join(words[:2]) if len(words) > 2 else event[:15] - result += f"{event_short} {event_type_abbrev}" - else: - result += event_type_abbrev - - # Add location (area description) if available - compact format - if area_desc: - # Extract first location from area_desc (often contains multiple locations) - # Format: "Seattle, WA" or "King County; Snohomish County" etc. - locations = [loc.strip() for loc in area_desc.split(';')] - first_location = locations[0] - - # Try to extract just city/area name if it's long - # e.g., "Seattle, WA" -> "Seattle" or "King County" -> "King" - if ',' in first_location: - # Has state/country - take just the city part - location_parts = first_location.split(',') - location_short = location_parts[0].strip() - else: - # No comma, might be "King County" -> take first word - location_words = first_location.split() - if len(location_words) > 1 and location_words[-1].lower() in ['county', 'parish', 'borough']: - location_short = location_words[0] - else: - location_short = first_location - - # Limit location length to keep message compact - if len(location_short) > 20: - location_short = location_short[:20] - - result += f" {location_short}" - - # Add expiration time if available - if expires: - til_label = self._translate('services.weather_service.til') - expires_compact = self._compact_time(expires) - if any(month in expires_compact for month in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]): - time_match = re.search(r'(\d+)(AM|PM)', expires_compact, re.IGNORECASE) - if time_match: - hour = time_match.group(1) - am_pm = time_match.group(2) - expires_short = f" {til_label} {hour}{am_pm}" - else: - expires_short = f" {til_label} {expires_compact[:15]}" - else: - time_match = re.search(r'(\d+):?(\d+)?(AM|PM)', expires_compact, re.IGNORECASE) - if time_match: - hour = time_match.group(1) - am_pm = time_match.group(3) - expires_short = f" {til_label} {hour}{am_pm}" - else: - expires_short = f" {til_label} {expires_compact[:15]}" - result += expires_short - - # Add office if available (abbreviate city name) - if office: - by_label = self._translate('services.weather_service.by') - office_parts = office.split() - if len(office_parts) >= 2: - office_org = office_parts[0] - city = office_parts[1] if len(office_parts) > 1 else "" - city_abbrev = self._abbreviate_city_name(city) - office_short = f" {by_label} {office_org} {city_abbrev}" - else: - office_short = f" {by_label} {office[:10]}" - result += office_short - - # Add shortened URL if available and there's space (within 130 char limit) - if link_url and len(result) < 100: # Leave ~30 chars for shortened URL - short_url = await self._shorten_url(link_url) - if short_url: - test_result = result + f" {short_url}" - if len(test_result) <= 130: # Mesh message limit - result = test_result - # If even shortened doesn't fit, try with just a link indicator - elif len(result) < 120: - result = result + " 🔗" - + if not include_details: return result - else: - return f"{severity_emoji}{event} {event_type_abbrev}" if event else f"{severity_emoji}{event_type_abbrev}" - def _compact_time(self, time_str: str) -> str: - """Compact time format (same as wx_command). + link_url = alert.get('link', '') + # Leave ~30 chars for the shortened URL inside the 130-char mesh limit. + if link_url and len(result) < 100: + short_url = await self._shorten_url(link_url) + if short_url: + with_url = f"{result} {short_url}" + if len(with_url) <= 130: + result = with_url + elif len(result) < 120: + # Even shortened it does not fit; signal that a link exists. + result += " 🔗" - Args: - time_str: Time string to format. - - Returns: - str: Compact formatted time string. - """ - if not time_str: - return time_str - - # Check if it's ISO format - if 'T' in time_str and re.match(r'\d{4}-\d{2}-\d{2}T', time_str): - try: - dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) - month = self._translate(f'services.weather_service.months.{["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][dt.month - 1]}') - day = dt.day - hour = dt.hour - - if hour == 0: - hour_12 = 12 - am_pm = self._translate('services.weather_service.am') - elif hour < 12: - hour_12 = hour - am_pm = self._translate('services.weather_service.am') - elif hour == 12: - hour_12 = 12 - am_pm = self._translate('services.weather_service.pm') - else: - hour_12 = hour - 12 - am_pm = self._translate('services.weather_service.pm') - - return f"{month} {day} {hour_12} {am_pm}" - except Exception: - pass - - # Remove leading zeros from hours - time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str) - - # Abbreviate month names - month_abbrevs = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - for abbrev in month_abbrevs: - translated = self._translate(f'services.weather_service.months.{abbrev}') - time_str = time_str.replace(abbrev, translated) - - # Remove "at" before time - time_str = re.sub(r'\s+at\s+', ' ', time_str) - - return time_str - - def _abbreviate_city_name(self, city: str) -> str: - """Abbreviate city names for compact display (same as wx_command). - - Args: - city: Full city name. - - Returns: - str: Abbreviated city name. - """ - if not city: - return city - - city_abbrevs = { - "Seattle": "SEA", "Portland": "PDX", "San Francisco": "SF", - "Los Angeles": "LA", "New York": "NYC", "Chicago": "CHI", - "Houston": "HOU", "Phoenix": "PHX", "Philadelphia": "PHL", - "San Antonio": "SAT", "San Diego": "SAN", "Dallas": "DAL", - "San Jose": "SJC", "Austin": "AUS", "Jacksonville": "JAX", - "Columbus": "CMH", "Fort Worth": "FTW", "Charlotte": "CLT", - "Denver": "DEN", "Washington": "DC", "Boston": "BOS", - "El Paso": "ELP", "Detroit": "DTW", "Nashville": "BNA", - "Oklahoma City": "OKC", "Las Vegas": "LAS", "Memphis": "MEM", - "Louisville": "SDF", "Baltimore": "BWI", "Milwaukee": "MKE", - "Albuquerque": "ABQ", "Tucson": "TUS", "Fresno": "FAT", - "Sacramento": "SAC", "Kansas City": "KC", "Mesa": "MSC", - "Atlanta": "ATL", "Omaha": "OMA", "Colorado Springs": "COS", - "Raleigh": "RDU", "Virginia Beach": "ORF", "Miami": "MIA", - "Oakland": "OAK", "Minneapolis": "MSP", "Tulsa": "TUL", - "Cleveland": "CLE", "Wichita": "ICT", "Arlington": "ARL", - "Tampa": "TPA", "New Orleans": "MSY", "Honolulu": "HNL", - "Anchorage": "ANC", "Bellingham": "BLI", "Everett": "EVE", - "Spokane": "GEG", "Tacoma": "TAC", "Yakima": "YKM", - "Olympia": "OLM", "Vancouver": "YVR", "Victoria": "YYJ" - } - - if city in city_abbrevs: - return city_abbrevs[city] - - for full_name, abbrev in city_abbrevs.items(): - if full_name in city: - return abbrev - - words = city.split() - if len(words) > 1: - abbrev = ''.join([w[0].upper() for w in words[:3]]) - if len(abbrev) <= 4: - return abbrev - - return city[:4].upper() if len(city) >= 4 else city.upper() + return result def _parse_iso_time(self, time_str: str) -> Optional[float]: """Parse ISO 8601 timestamp to Unix timestamp. diff --git a/pyproject.toml b/pyproject.toml index ecb5609..48bb486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,7 @@ strict_optional = true # New modules written with full type annotations get strict treatment. [[tool.mypy.overrides]] module = [ + "modules.alert_format", "modules.commands.schedule_command", "modules.service_plugins.webhook_service", "modules.service_plugins.base_service", diff --git a/tests/unit/test_alert_format.py b/tests/unit/test_alert_format.py new file mode 100644 index 0000000..f742847 --- /dev/null +++ b/tests/unit/test_alert_format.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Unit tests for the shared NWS alert formatter (modules.alert_format).""" + +import pytest + +from modules import alert_format as af +from modules.i18n import Translator + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def en(): + return Translator("en") + + +@pytest.fixture +def ru(): + return Translator("ru") + + +def alert(**overrides): + base = { + "event": "Flood Warning", + "event_type": "Warning", + "severity": "Severe", + "expires": "2026-06-28T18:00:00-07:00", + "office": "NWS Seattle WA", + "area_desc": "King County; Snohomish County", + } + base.update(overrides) + return base + + +class TestTranslateOr: + def test_present_key_translates(self, ru): + assert af.translate_or(ru, "common.unknown", "Unknown") == "Неизвестно" + + def test_missing_key_uses_default(self, en): + assert af.translate_or(en, "no.such.key.at.all", "fallback") == "fallback" + + def test_missing_key_formats_default(self, en): + assert af.translate_or(en, "no.such.key", "{n} left", n=3) == "3 left" + + def test_no_translator_uses_default(self): + assert af.translate_or(None, "common.unknown", "Unknown") == "Unknown" + + +class TestEventTypeAbbrev: + @pytest.mark.parametrize( + ("event_type", "expected"), + [("Warning", "Warn"), ("Watch", "Watch"), ("Advisory", "Adv"), ("Statement", "Stmt")], + ) + def test_known_types(self, en, event_type, expected): + assert af.event_type_abbrev(event_type, en) == expected + + def test_unknown_type_never_leaks_a_key(self, en): + # _parse_alert_entry emits "Unknown" for any title we cannot classify. + assert af.event_type_abbrev("Unknown", en) == "Unknown" + + def test_unknown_type_never_leaks_a_key_localized(self, ru): + assert af.event_type_abbrev("Unknown", ru) == "Unknown" + + def test_empty_type(self, en): + assert af.event_type_abbrev("", en) == "" + + def test_localized_abbreviations_stay_abbreviations(self, ru): + # Full words would eat a quarter of the 130-byte budget at 2 bytes/char. + for event_type in ("Warning", "Watch", "Advisory", "Statement"): + assert len(af.event_type_abbrev(event_type, ru).encode("utf-8")) <= 16 + + +class TestCompactTime: + def test_iso_english(self, en): + assert af.compact_time("2026-06-28T18:00:00-07:00", en) == "Jun 28 6PM" + + def test_iso_midnight_and_noon(self, en): + assert af.compact_time("2026-06-28T00:30:00-07:00", en) == "Jun 28 12AM" + assert af.compact_time("2026-06-28T12:00:00-07:00", en) == "Jun 28 12PM" + + def test_iso_russian_separates_hour_from_meridiem(self, ru): + # "6дня" is not Russian; the locale owns the separator. + assert af.compact_time("2026-06-28T18:00:00-07:00", ru) == "июн 28 6 дня" + + def test_full_month_name_is_abbreviated(self, en): + assert af.compact_time("June 28 at 6:00AM", en) == "Jun 28 6AM" + + def test_full_month_name_is_not_corrupted_mid_word(self, ru): + # Replacing the "Jun" inside "June" used to leave a stray Latin "e". + assert af.compact_time("June 28 at 6:00AM", ru) == "июн 28 6 утра" + + @pytest.mark.parametrize("month", ["March", "April", "May", "July", "August"]) + def test_no_latin_residue_in_any_month(self, ru, month): + out = af.compact_time(f"{month} 3 at 1:00PM", ru) + assert not any(ch.isascii() and ch.isalpha() for ch in out), out + + def test_minutes_are_kept(self, en): + assert af.compact_time("December 16 at 3:12PM", en) == "Dec 16 3:12PM" + + def test_empty_passes_through(self, en): + assert af.compact_time("", en) == "" + + def test_unparseable_iso_falls_back_to_text_path(self, en): + assert af.compact_time("2026-13-45T99:00:00", en) == "2026-13-45T99:00:00" + + def test_no_translator_is_english(self): + assert af.compact_time("2026-06-28T18:00:00-07:00") == "Jun 28 6PM" + + +class TestExtractClock: + def test_reads_clock_off_english_source(self, en): + assert af.extract_clock("December 17 at 6:00AM PST", en) == "6AM" + + def test_keeps_real_minutes(self, en): + assert af.extract_clock("December 16 at 3:12PM PST", en) == "3:12PM" + + def test_localized(self, ru): + assert af.extract_clock("December 17 at 6:00AM PST", ru) == "6 утра" + + def test_no_clock_returns_none(self, en): + assert af.extract_clock("sometime tomorrow", en) is None + + +class TestFormatAlertCompact: + def test_english_iso_expiry_is_time_only(self, en): + assert af.format_alert_compact(alert(), en) == "🟠Flood Warning King til 6PM by NWS SEA" + + def test_russian_iso_expiry_is_time_only(self, ru): + # The English-month check plus an (AM|PM) regex used to send every + # non-English locale down a mid-string truncation. + assert af.format_alert_compact(alert(), ru) == "🟠Flood Warning King до 6 дня от NWS SEA" + + def test_free_text_expiry_is_time_only(self, en): + out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), en) + assert out == "🟠Flood Warning King til 6AM by NWS SEA" + + def test_free_text_expiry_is_not_truncated_mid_word(self, ru): + out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), ru) + assert out == "🟠Flood Warning King до 6 утра от NWS SEA" + + def test_unknown_event_type_never_leaks_a_key(self, en): + out = af.format_alert_compact( + alert(event="Hazardous", event_type="Unknown", severity="Minor", + expires="", office="", area_desc=""), + en, + ) + assert out == "⚪Hazardous Unknown" + + def test_summary_form_omits_details(self, en): + out = af.format_alert_compact(alert(), en, include_details=False) + assert out == "🟠Flood Warning Warn" + + def test_location_can_be_suppressed(self, en): + out = af.format_alert_compact(alert(), en, include_location=False) + assert out == "🟠Flood Warning til 6PM by NWS SEA" + + def test_redundant_event_type_is_dropped(self, en): + # "Flood Warning" already says "Warning". + assert "Warn " not in af.format_alert_compact(alert(), en) + + def test_event_type_is_appended_when_not_redundant(self, en): + out = af.format_alert_compact( + alert(event="Dense Fog", event_type="Advisory", expires="", office="", area_desc=""), en + ) + assert out == "🟠Dense Fog Adv" + + def test_severity_emoji(self, en): + for severity, emoji in (("Extreme", "🔴"), ("Severe", "🟠"), + ("Moderate", "🟡"), ("Minor", "⚪"), ("Nonsense", "⚪")): + out = af.format_alert_compact( + alert(severity=severity, expires="", office="", area_desc=""), en + ) + assert out.startswith(emoji) + + def test_fits_the_mesh_budget_in_both_locales(self, en, ru): + for translator in (en, ru): + out = af.format_alert_compact(alert(), translator) + assert len(out.encode("utf-8")) <= 130, out + + +class TestShortenEvent: + def test_short_name_untouched(self): + assert af.shorten_event("Dense Fog") == "Dense Fog" + + def test_long_name_keeps_two_words(self): + assert af.shorten_event("Excessive Heat Warning Extended") == "Excessive Heat" + + def test_single_long_word_is_cut(self): + assert af.shorten_event("Thunderstormageddon") == "Thunderstormage" + + def test_summary_mode_keeps_one_word(self): + assert af.shorten_event("Excessive Heat Warning", limit=12, max_words=1) == "Excessive" + + def test_none_limit_never_trims(self): + assert af.shorten_event("Excessive Heat Warning Extended", limit=None) == \ + "Excessive Heat Warning Extended" + + +class TestLocation: + def test_strips_state(self): + assert af.first_location("Seattle, WA") == "Seattle" + + def test_strips_county(self): + assert af.first_location("King County; Snohomish County") == "King" + + def test_keeps_multiword_place(self): + assert af.first_location("Puget Sound") == "Puget Sound" + + def test_truncates_long_name(self): + assert len(af.first_location("A" * 40)) == 20 + + def test_empty(self): + assert af.first_location("") == "" + + +class TestOffice: + def test_known_city(self, en): + assert af.format_office("NWS Seattle WA", en) == "by NWS SEA" + + def test_localized_label(self, ru): + assert af.format_office("NWS Seattle WA", ru) == "от NWS SEA" + + def test_single_token(self, en): + assert af.format_office("NWS", en) == "by NWS" + + def test_empty(self, en): + assert af.format_office("", en) == "" + + +class TestCityAbbrev: + @pytest.mark.parametrize(("city", "expected"), + [("Seattle", "SEA"), ("Seattle WA", "SEA"), ("Portland", "PDX")]) + def test_known(self, city, expected): + assert af.abbreviate_city_name(city) == expected + + def test_initials_for_unknown_multiword(self): + assert af.abbreviate_city_name("Little Rock") == "LR" + + def test_prefix_for_unknown_single_word(self): + assert af.abbreviate_city_name("Bozeman") == "BOZE" + + def test_empty(self): + assert af.abbreviate_city_name("") == "" + + +class TestAlertWindow: + def test_both_ends(self, en): + out = af.format_alert_window( + {"effective": "2026-06-28T15:00:00-07:00", "expires": "2026-06-29T06:00:00-07:00"}, en + ) + assert out == "from Jun 28 3PM til Jun 29 6AM" + + def test_localized(self, ru): + out = af.format_alert_window({"expires": "2026-06-29T06:00:00-07:00"}, ru) + assert out == "до июн 29 6 утра" + + def test_no_times(self, en): + assert af.format_alert_window({}, en) == "" diff --git a/tests/unit/test_gwx_display_units.py b/tests/unit/test_gwx_display_units.py new file mode 100644 index 0000000..72868de --- /dev/null +++ b/tests/unit/test_gwx_display_units.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Unit tests for !gwx display units — driven by [Weather] config, not by locale.""" + +import pytest + +from modules.commands.alternatives.wx_international import ( + HPA_TO_MMHG, + MI_TO_KM, + VISIBILITY_CAP_KM, + VISIBILITY_CAP_MI, + GlobalWxCommand, +) +from modules.i18n import Translator + + +@pytest.fixture +def gwx_bot(mock_bot): + mock_bot.config.add_section("Weather") + mock_bot.config.set("Weather", "weather_provider", "openmeteo") + mock_bot.config.set("Weather", "default_country", "US") + return mock_bot + + +def build(gwx_bot, *, temperature_unit="fahrenheit", language="en"): + gwx_bot.config.set("Weather", "temperature_unit", temperature_unit) + gwx_bot.translator = Translator(language) + return GlobalWxCommand(gwx_bot) + + +@pytest.mark.unit +class TestMetricDistance: + def test_fahrenheit_is_imperial(self, gwx_bot): + assert build(gwx_bot, temperature_unit="fahrenheit").metric_distance is False + + def test_celsius_is_metric(self, gwx_bot): + assert build(gwx_bot, temperature_unit="celsius").metric_distance is True + + def test_language_does_not_decide(self, gwx_bot): + # A Fahrenheit bot answering in Russian must not print kilometers next + # to Fahrenheit temperatures. + cmd = build(gwx_bot, temperature_unit="fahrenheit", language="ru") + assert cmd.metric_distance is False + + def test_en_gb_with_celsius_gets_metric(self, gwx_bot): + cmd = build(gwx_bot, temperature_unit="celsius", language="en-GB") + assert cmd.metric_distance is True + + def test_invalid_unit_falls_back_to_imperial(self, gwx_bot): + cmd = build(gwx_bot, temperature_unit="kelvin") + assert cmd.temperature_unit == "fahrenheit" + assert cmd.metric_distance is False + + +@pytest.mark.unit +class TestVisibilityStrings: + def test_imperial_string_says_miles_in_english(self): + assert Translator("en").translate("commands.gwx.visibility", value=12) == "👁️12mi" + + def test_imperial_string_says_miles_in_russian(self): + # This key means miles in every catalog now that the unit comes from + # config; it used to say "км" in ru. + out = Translator("ru").translate("commands.gwx.visibility", value=12) + assert "км" not in out + assert "миль" in out + + def test_metric_string_says_km(self): + assert Translator("en").translate("commands.gwx.visibility_km", value=12) == "👁️12km" + + def test_caps_are_equivalent(self): + assert pytest.approx(VISIBILITY_CAP_KM, abs=0.3) == VISIBILITY_CAP_MI * MI_TO_KM + + +@pytest.mark.unit +class TestPressureUnitConvention: + @pytest.mark.parametrize("language", ["en", "en-GB", "de", "fr", "fr-CA", + "es", "nl", "pl", "pt", "pt-BR"]) + def test_hpa_locales(self, language): + assert Translator(language).translate("commands.gwx.pressure_unit") == "hpa" + + def test_russian_uses_mmhg(self): + assert Translator("ru").translate("commands.gwx.pressure_unit") == "mmhg" + + @pytest.mark.parametrize("language", ["en", "de", "fr", "es", "nl", "pl", "pt"]) + def test_hpa_locales_render_no_cyrillic(self, language): + out = Translator(language).translate("commands.gwx.pressure", value=1013) + assert not any("Ѐ" <= ch <= "ӿ" for ch in out), out + + def test_english_mmhg_string_is_english(self): + # The en catalog held "мм рт. ст.", which every non-ru locale inherited. + out = Translator("en").translate("commands.gwx.pressure_mmhg", value=760) + assert out == "📊760mmHg" + + def test_conversion_factor(self): + assert round(1013 * HPA_TO_MMHG) == 760 diff --git a/tests/unit/test_translation_catalog_hygiene.py b/tests/unit/test_translation_catalog_hygiene.py new file mode 100644 index 0000000..8b1db8f --- /dev/null +++ b/tests/unit/test_translation_catalog_hygiene.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Checks across the shipped translation catalogs. + +The English catalog is every other locale's fallback, so a wrong string there +leaks into nine other languages. These are the two mistakes that has actually +produced: a translated string committed to en.json, and a key defined only in +a non-English catalog (which then falls back to its own key path). +""" + +import json +import re +from pathlib import Path + +import pytest + +TRANSLATIONS = Path(__file__).resolve().parents[2] / "translations" +CATALOGS = sorted(p for p in TRANSLATIONS.glob("*.json")) +NON_ENGLISH = [p for p in CATALOGS if p.stem not in ("en", "en-GB")] + + +def flatten(node, prefix=""): + """Flatten a catalog into {dotted.key: value}.""" + flat = {} + for key, value in node.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + flat.update(flatten(value, path)) + else: + flat[path] = value + return flat + + +def load(path): + with open(path, encoding="utf-8") as handle: + return flatten(json.load(handle)) + + +@pytest.fixture(scope="module") +def english(): + return load(TRANSLATIONS / "en.json") + + +@pytest.mark.unit +def test_catalogs_exist(): + assert len(CATALOGS) >= 10, [p.name for p in CATALOGS] + + +@pytest.mark.unit +def test_english_catalog_holds_no_cyrillic(english): + offenders = {k: v for k, v in english.items() + if isinstance(v, str) and any("Ѐ" <= ch <= "ӿ" for ch in v)} + assert offenders == {}, f"non-English text in en.json: {offenders}" + + +@pytest.mark.unit +@pytest.mark.parametrize("path", NON_ENGLISH, ids=lambda p: p.stem) +def test_no_key_is_defined_only_outside_english(english, path): + """A key missing from en.json has no fallback and renders as its key path.""" + orphans = sorted(set(load(path)) - set(english)) + assert orphans == [], f"{path.name} defines keys absent from en.json: {orphans}" + + +# Placeholder drift predates this suite across most command namespaces; these +# are the namespaces the weather/alert localization owns. +WEATHER_PREFIXES = ( + "commands.gwx.", "commands.wx.", "commands.rain.", + "services.weather_service.", "common.alerts.", "common.wind_directions.", + "common.date_time.", "common.temp_", +) + + +@pytest.mark.unit +@pytest.mark.parametrize("path", CATALOGS, ids=lambda p: p.stem) +def test_weather_placeholders_match_english(english, path): + """A locale that drops or renames a {placeholder} raises at format() time.""" + mismatches = {} + for key, value in load(path).items(): + if not key.startswith(WEATHER_PREFIXES): + continue + expected = english.get(key) + if not isinstance(value, str) or not isinstance(expected, str): + continue + if set(re.findall(r"\{(\w+)\}", value)) != set(re.findall(r"\{(\w+)\}", expected)): + mismatches[key] = (expected, value) + assert mismatches == {}, f"{path.name} placeholder drift: {mismatches}" + + +@pytest.mark.unit +def test_alert_strings_live_under_common(english): + """Shared by !wx alerts and WeatherService, so not under a service namespace.""" + for key in ("common.alerts.til", "common.alerts.by", "common.alerts.from", + "common.alerts.am", "common.alerts.pm", + "common.alerts.time_12h", "common.alerts.date_12h"): + assert key in english, key + for event_type in ("Warning", "Watch", "Advisory", "Statement"): + assert f"common.alerts.event_types.{event_type}" in english + + +@pytest.mark.unit +def test_months_are_not_duplicated(english): + """services.weather_service.months duplicated common.date_time.*.""" + assert not [k for k in english if k.startswith("services.weather_service.months.")] + assert "common.date_time.month_abbreviations.Jan" in english + assert "common.date_time.months.January" in english + + +@pytest.mark.unit +def test_wind_directions_live_under_common(english): + """Read from a command module as well as the service.""" + assert "common.wind_directions.N" in english + assert not [k for k in english if k.startswith("services.weather_service.wind_directions.")] diff --git a/tests/unit/test_weather_service_localization.py b/tests/unit/test_weather_service_localization.py new file mode 100644 index 0000000..6868b35 --- /dev/null +++ b/tests/unit/test_weather_service_localization.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Unit tests for WeatherService's localized daily-forecast pieces. + +Covers the presentation helpers the localization pass rewrote: an unmapped WMO +code, a wind-speed unit the operator wrote in unexpected casing, and the +compass direction lookup. All three used to render a translation key path into +a channel broadcast when the lookup missed. +""" + +import configparser +from unittest.mock import Mock + +import pytest + +from modules.i18n import Translator +from modules.service_plugins.weather_service import WeatherService + +pytestmark = pytest.mark.unit + + +def build_service(*, language="en", weather_overrides=None): + cfg = configparser.ConfigParser() + cfg.add_section("Weather") + cfg.add_section("Weather_Service") + cfg.set("Weather_Service", "my_position_lat", "47.6") + cfg.set("Weather_Service", "my_position_lon", "-122.3") + for key, value in (weather_overrides or {}).items(): + cfg.set("Weather", key, value) + + bot = Mock() + bot.logger = Mock() + bot.config = cfg + bot.db_manager = Mock() + bot.translator = Translator(language, "translations/") + + service = WeatherService(bot) + service.api_session = Mock() + return service + + +class TestUnitConfigNormalization: + def test_casing_is_normalized(self): + service = build_service(weather_overrides={"wind_speed_unit": "KMH"}) + assert service.wind_speed_unit == "kmh" + + def test_unknown_unit_falls_back(self): + service = build_service(weather_overrides={"wind_speed_unit": "furlongs/fortnight"}) + assert service.wind_speed_unit == "mph" + + def test_temperature_and_precipitation_normalized(self): + service = build_service(weather_overrides={ + "temperature_unit": "Celsius", "precipitation_unit": "MM", + }) + assert service.temperature_unit == "celsius" + assert service.precipitation_unit == "mm" + + @pytest.mark.parametrize(("unit", "label"), + [("mph", "mph"), ("kmh", "km/h"), ("ms", "m/s"), ("kn", "kn")]) + def test_every_valid_unit_has_a_label(self, unit, label): + service = build_service(weather_overrides={"wind_speed_unit": unit}) + from modules import alert_format as af + rendered = af.translate_or( + service.bot.translator, + f"services.weather_service.wind_speed_units.{service.wind_speed_unit}", + service.wind_speed_unit, + ) + assert rendered == label + + def test_odd_casing_never_renders_a_key_path(self): + service = build_service(weather_overrides={"wind_speed_unit": "KMH"}) + from modules import alert_format as af + rendered = af.translate_or( + service.bot.translator, + f"services.weather_service.wind_speed_units.{service.wind_speed_unit}", + service.wind_speed_unit, + ) + assert "services.weather_service" not in rendered + + +class TestWeatherDescription: + def test_mapped_code(self): + assert build_service()._get_weather_description(0) == "Clear" + + def test_mapped_code_localized(self): + assert build_service(language="ru")._get_weather_description(0) == "Ясно" + + @pytest.mark.parametrize("code", [4, 7, 123, 999]) + def test_unmapped_code_says_unknown(self, code): + assert build_service()._get_weather_description(code) == "Unknown" + + def test_unmapped_code_localized(self): + assert build_service(language="ru")._get_weather_description(4) == "Неизвестно" + + @pytest.mark.parametrize("code", [4, 999]) + def test_unmapped_code_never_renders_a_key_path(self, code): + for language in ("en", "ru", "de"): + out = build_service(language=language)._get_weather_description(code) + assert "weather_descriptions" not in out, out + + +class TestWindDirection: + @pytest.mark.parametrize(("degrees", "expected"), + [(0, "N"), (45, "NE"), (90, "E"), (180, "S"), (270, "W"), (359, "N")]) + def test_english_compass(self, degrees, expected): + assert build_service()._degrees_to_direction(degrees) == expected + + def test_localized_compass(self): + assert build_service(language="ru")._degrees_to_direction(292.5) == "ЗСЗ" + + def test_none_is_empty(self): + assert build_service()._degrees_to_direction(None) == "" + + def test_never_renders_a_key_path(self): + service = build_service(language="de") + for degrees in range(0, 360, 15): + assert "wind_directions" not in service._degrees_to_direction(degrees) + + +class TestAlertFormattingIsShared: + @pytest.mark.asyncio + async def test_matches_the_shared_helper(self): + from modules import alert_format as af + + service = build_service() + alert = { + "event": "Flood Warning", "event_type": "Warning", "severity": "Severe", + "expires": "2026-06-28T18:00:00-07:00", "office": "NWS Seattle WA", + "area_desc": "King County", "link": "", + } + assert await service._format_alert_compact(alert) == af.format_alert_compact( + alert, service.bot.translator + ) + + @pytest.mark.asyncio + async def test_unknown_event_type_never_renders_a_key_path(self): + service = build_service(language="ru") + alert = { + "event": "Hazardous", "event_type": "Unknown", "severity": "Minor", + "expires": "", "office": "", "area_desc": "", "link": "", + } + assert await service._format_alert_compact(alert) == "⚪Hazardous Unknown" diff --git a/translations/en.json b/translations/en.json index 350402c..10cecf2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -223,6 +223,7 @@ "error": "Error getting weather data: {error}", "alerts": "{count} alerts: {text}", "tomorrow_not_available": "Tomorrow's forecast not available", + "hourly_not_available": "Hourly forecast not available", "tomorrow_error": "Error formatting tomorrow's forecast", "multiday_not_available": "{num_days}-day forecast not available", "multiday_error": "Error formatting {num_days}-day forecast", @@ -232,41 +233,41 @@ "mqtt_weather_stale": "MQTT weather data is too old", "mqtt_weather_payload_error": "MQTT weather payload error: {detail}" }, - "gwx": { - "description": "Get weather for any worldwide location (use: gwx Tokyo)", - "help": "Usage: gwx - Weather for any location worldwide (city, country, or coordinates)", - "usage": "Usage: gwx - Example: gwx Tokyo or gwx Paris, France", - "error_fetching": "Error fetching weather data", - "error_fetching_api": "Error fetching weather data from Open-Meteo", - "no_location": "Could not find location '{location}'", - "error": "Error fetching weather data: {error}", - "tomorrow_not_available": "Tomorrow forecast not available", - "tomorrow_error": "Error formatting tomorrow forecast", - "multiday_not_available": "{num_days}-day forecast not available", - "multiday_error": "Error formatting {num_days}-day forecast", - "mqtt_forecast_not_supported": "Extended forecast not supported for MQTT weather sources", - "mqtt_weather_no_subscriber": "MQTT weather subscriber not active", - "mqtt_weather_no_data": "MQTT weather message not yet received", - "mqtt_weather_stale": "MQTT weather data is stale", - "mqtt_weather_payload_error": "MQTT weather payload error: {detail}", - "feels_like": "(feels {value}{unit})", - "humidity": "{value}%RH", - "dew_point": "💧{value}{unit}", - "visibility": "👁️{value}mi", - "visibility_km": "👁️{value}km", - "pressure": "📊{value}hPa", - "pressure_mmhg": "📊{value}мм рт. ст.", - "gust": "G{value}", - "day_abbrev": { - "Monday": "M", - "Tuesday": "T", - "Wednesday": "W", - "Thursday": "Th", - "Friday": "F", - "Saturday": "Sa", - "Sunday": "Su" - }, - "periods": { + "gwx": { + "description": "Get weather information for any global location (usage: gwx Tokyo)", + "help": "Usage: gwx - Get weather for any global location (city, country, or coordinates)", + "usage": "Usage: gwx - Example: gwx Tokyo or gwx Paris, France", + "error_fetching": "Error fetching weather data", + "error_fetching_api": "Error fetching weather data from Open-Meteo", + "no_location": "Could not find location '{location}'", + "error": "Error getting weather data: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", + "mqtt_forecast_not_supported": "Extended forecast is not available for MQTT weather sources", + "mqtt_weather_no_subscriber": "MQTT weather subscriber is not active (enable [MqttWeather] and custom.mqtt_weather.* topics)", + "mqtt_weather_no_data": "No MQTT weather message received for this topic yet", + "mqtt_weather_stale": "MQTT weather data is too old", + "mqtt_weather_payload_error": "MQTT weather payload error: {detail}", + "feels_like": "(feels {value}{unit})", + "humidity": "{value}%RH", + "dew_point": "💧{value}{unit}", + "visibility": "👁️{value}mi", + "visibility_km": "👁️{value}km", + "pressure": "📊{value}hPa", + "pressure_mmhg": "📊{value}mmHg", + "gust": "G{value}", + "day_abbrev": { + "Monday": "M", + "Tuesday": "T", + "Wednesday": "W", + "Thursday": "Th", + "Friday": "F", + "Saturday": "Sa", + "Sunday": "Su" + }, + "periods": { "today": "Today", "tonight": "Tonight", "tomorrow": "Tomorrow" @@ -309,7 +310,8 @@ "thunderstorms": "⚠️ Thunderstorms", "heavy_snow": "⚠️ Heavy snow", "high_winds": "⚠️ High winds ({wind_speed} mph)" - } + }, + "pressure_unit": "hpa" }, "sports": { "description": "Get sports scores and schedules (usage: sports [team/league])", @@ -1423,6 +1425,39 @@ "November": "November", "December": "December" } + }, + "alerts": { + "event_types": { + "Warning": "Warn", + "Watch": "Watch", + "Advisory": "Adv", + "Statement": "Stmt" + }, + "til": "til", + "by": "by", + "from": "from", + "am": "AM", + "pm": "PM", + "time_12h": "{hour}{meridiem}", + "date_12h": "{month} {day} {time}" + }, + "wind_directions": { + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW" } }, "categories": { @@ -1490,48 +1525,6 @@ "96": "Thunderstorm w/Hail", "99": "Severe Thunderstorm" }, - "event_types": { - "Warning": "Warn", - "Watch": "Watch", - "Advisory": "Adv", - "Statement": "Stmt" - }, - "months": { - "Jan": "Jan", - "Feb": "Feb", - "Mar": "Mar", - "Apr": "Apr", - "May": "May", - "Jun": "Jun", - "Jul": "Jul", - "Aug": "Aug", - "Sep": "Sep", - "Oct": "Oct", - "Nov": "Nov", - "Dec": "Dec" - }, - "am": "AM", - "pm": "PM", - "til": "til", - "by": "by", - "wind_directions": { - "N": "N", - "NNE": "NNE", - "NE": "NE", - "ENE": "ENE", - "E": "E", - "ESE": "ESE", - "SE": "SE", - "SSE": "SSE", - "S": "S", - "SSW": "SSW", - "SW": "SW", - "WSW": "WSW", - "W": "W", - "WNW": "WNW", - "NW": "NW", - "NNW": "NNW" - }, "wind_speed_units": { "ms": "m/s", "mph": "mph", diff --git a/translations/ru.json b/translations/ru.json index 0a5ba75..1c78660 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -1,46 +1,182 @@ { "keywords": { - "help": ["help"], - "ping": ["ping"], - "test": ["test", "t"], - "wx": ["wx", "weather", "wxa", "wxalert"], - "gwx": ["gwx", "globalweather", "gwxa"], - "aqi": ["aqi", "air", "airquality", "air_quality"], - "rain": ["rain", "nowcast", "snow"], - "aurora": ["aurora", "kp"], - "solar": ["solar"], - "sun": ["sun"], - "moon": ["moon"], - "hfcond": ["hfcond", "hf"], - "satpass": ["satpass"], - "sports": ["sports", "score", "scores"], - "worldcup": ["wc", "worldcup"], - "stats": ["stats"], - "channels": ["channels", "channel"], - "path": ["path", "decode", "route"], - "prefix": ["prefix", "repeater", "lookup"], - "repeater": ["repeater", "repeaters", "rp"], - "solarforecast": ["sf", "solarforecast"], - "dice": ["dice"], - "roll": ["roll"], - "joke": ["joke", "jokes"], - "dadjoke": ["dadjoke", "dad joke", "dadjokes", "dad jokes"], - "catfact": ["catfact", "cat", "meow", "purr", "kitten"], - "hacker": ["hacker", "sudo", "ps aux", "grep", "ls -l", "ls -la", "echo $PATH"], - "hello": [ - "hello", "hi", "hey", "howdy", "greetings", "salutations", - "good morning", "good afternoon", "good evening", "good night", - "yo", "sup", "whats up", "what's up", "morning", "afternoon", - "evening", "night", "gday", "g'day", "hola", "bonjour", "ciao", - "namaste", "aloha", "shalom", "konnichiwa", "guten tag", - "buenos dias", "buenas tardes", "buenas noches" + "help": [ + "help" ], - "webviewer": ["webviewer", "web", "viewer", "wv"], - "cmd": ["cmd", "commands"], - "advert": ["advert"], - "neighbors": ["neighbors", "neighbours"], - "multitest": ["multitest", "mt"], - "trace": ["trace", "tracer"] + "ping": [ + "ping" + ], + "test": [ + "test", + "t" + ], + "wx": [ + "wx", + "weather", + "wxa", + "wxalert" + ], + "gwx": [ + "gwx", + "globalweather", + "gwxa" + ], + "aqi": [ + "aqi", + "air", + "airquality", + "air_quality" + ], + "rain": [ + "rain", + "nowcast", + "snow" + ], + "aurora": [ + "aurora", + "kp" + ], + "solar": [ + "solar" + ], + "sun": [ + "sun" + ], + "moon": [ + "moon" + ], + "hfcond": [ + "hfcond", + "hf" + ], + "satpass": [ + "satpass" + ], + "sports": [ + "sports", + "score", + "scores" + ], + "worldcup": [ + "wc", + "worldcup" + ], + "stats": [ + "stats" + ], + "channels": [ + "channels", + "channel" + ], + "path": [ + "path", + "decode", + "route" + ], + "prefix": [ + "prefix", + "repeater", + "lookup" + ], + "repeater": [ + "repeater", + "repeaters", + "rp" + ], + "solarforecast": [ + "sf", + "solarforecast" + ], + "dice": [ + "dice" + ], + "roll": [ + "roll" + ], + "joke": [ + "joke", + "jokes" + ], + "dadjoke": [ + "dadjoke", + "dad joke", + "dadjokes", + "dad jokes" + ], + "catfact": [ + "catfact", + "cat", + "meow", + "purr", + "kitten" + ], + "hacker": [ + "hacker", + "sudo", + "ps aux", + "grep", + "ls -l", + "ls -la", + "echo $PATH" + ], + "hello": [ + "hello", + "hi", + "hey", + "howdy", + "greetings", + "salutations", + "good morning", + "good afternoon", + "good evening", + "good night", + "yo", + "sup", + "whats up", + "what's up", + "morning", + "afternoon", + "evening", + "night", + "gday", + "g'day", + "hola", + "bonjour", + "ciao", + "namaste", + "aloha", + "shalom", + "konnichiwa", + "guten tag", + "buenos dias", + "buenas tardes", + "buenas noches" + ], + "webviewer": [ + "webviewer", + "web", + "viewer", + "wv" + ], + "cmd": [ + "cmd", + "commands" + ], + "advert": [ + "advert" + ], + "neighbors": [ + "neighbors", + "neighbours" + ], + "multitest": [ + "multitest", + "mt" + ], + "trace": [ + "trace", + "tracer" + ] }, "commands": { "help": { @@ -64,10 +200,22 @@ "description": "Получить погоду по почтовому индексу (использование: wx 12345)", "usage": "Использование: wx <индекс|город> - Пример: wx 12345 или wx seattle или wx paris, tx", "subcommands": [ - {"name": "tomorrow", "description": "Прогноз на завтра"}, - {"name": "Nd", "description": "Прогноз на N дней (напр. 7d, 10d)"}, - {"name": "hourly", "description": "Почасовой прогноз"}, - {"name": "alerts", "description": "Полныеweather-оповещения"} + { + "name": "tomorrow", + "description": "Прогноз на завтра" + }, + { + "name": "Nd", + "description": "Прогноз на N дней (напр. 7d, 10d)" + }, + { + "name": "hourly", + "description": "Почасовой прогноз" + }, + { + "name": "alerts", + "description": "Полныеweather-оповещения" + } ], "error_fetching": "Ошибка получения данных погоды из NOAA", "no_location_zipcode": "Не удалось найти локацию для индекса {location}", @@ -84,76 +232,76 @@ "mqtt_weather_stale": "Данные MQTT-погоды слишком старые", "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT-погоды: {detail}" }, - "gwx": { - "description": "Получить погоду для любой точки мира (использование: gwx Tokyo)", - "help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)", - "usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France", - "error_fetching": "Ошибка получения данных погоды", - "error_fetching_api": "Ошибка получения данных погоды из Open-Meteo", - "no_location": "Не удалось найти локацию '{location}'", - "error": "Ошибка получения данных погоды: {error}", - "tomorrow_not_available": "Прогноз на завтра недоступен", - "tomorrow_error": "Ошибка форматирования прогноза на завтра", - "multiday_not_available": "Прогноз на {num_days} дней недоступен", - "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", - "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", - "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен", - "mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено", - "mqtt_weather_stale": "Данные MQTT-погоды устарели", - "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}", - "feels_like": "(ош.{value}{unit})", - "humidity": "{value}%", - "dew_point": "💧{value}{unit}", - "visibility": "👁️{value}км", - "visibility_km": "👁️{value}км", - "pressure": "📊{value}гПа", - "pressure_mmhg": "📊{value} мм рт. ст.", - "gust": "G{value}", - "day_abbrev": { - "Monday": "Пн", - "Tuesday": "Вт", - "Wednesday": "Ср", - "Thursday": "Чт", - "Friday": "Пт", - "Saturday": "Сб", - "Sunday": "Вс" - }, - "periods": { - "today": "Сегодня", - "tonight": "Сегодня ночью", - "tomorrow": "Завтра" - }, - "weather_descriptions": { - "0": "Ясно", - "1": "Преим.ясно", - "2": "Перем.облач.", - "3": "Пасмурно", - "45": "Туман", - "48": "Туман", - "51": "Лёгк.морось", - "53": "Морось", - "55": "Сильн.морось", - "56": "Лёгк.лед.морось", - "57": "Лед.морось", - "61": "Небол.дождь", - "63": "Дождь", - "65": "Сильн.дождь", - "66": "Слаб.лед.дождь", - "67": "Лед.дождь", - "71": "Небол.снег", - "73": "Снег", - "75": "Сильн.снег", - "77": "Снеж.зерна", - "80": "Небол.ливень", - "81": "Ливень", - "82": "Сильн.ливень", - "85": "Слаб.снеж.ливень", - "86": "Снеж.ливень", - "95": "Гроза", - "96": "Гроза+град", - "99": "Сильн.гроза", - "unknown": "Неизвестно" - }, + "gwx": { + "description": "Получить погоду для любой точки мира (использование: gwx Tokyo)", + "help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)", + "usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France", + "error_fetching": "Ошибка получения данных погоды", + "error_fetching_api": "Ошибка получения данных погоды из Open-Meteo", + "no_location": "Не удалось найти локацию '{location}'", + "error": "Ошибка получения данных погоды: {error}", + "tomorrow_not_available": "Прогноз на завтра недоступен", + "tomorrow_error": "Ошибка форматирования прогноза на завтра", + "multiday_not_available": "Прогноз на {num_days} дней недоступен", + "multiday_error": "Ошибка форматирования прогноза на {num_days} дней", + "mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды", + "mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен", + "mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено", + "mqtt_weather_stale": "Данные MQTT-погоды устарели", + "mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}", + "feels_like": "(ош.{value}{unit})", + "humidity": "{value}%", + "dew_point": "💧{value}{unit}", + "visibility": "👁️{value} миль", + "visibility_km": "👁️{value}км", + "pressure": "📊{value}гПа", + "pressure_mmhg": "📊{value} мм рт. ст.", + "gust": "G{value}", + "day_abbrev": { + "Monday": "Пн", + "Tuesday": "Вт", + "Wednesday": "Ср", + "Thursday": "Чт", + "Friday": "Пт", + "Saturday": "Сб", + "Sunday": "Вс" + }, + "periods": { + "today": "Сегодня", + "tonight": "Сегодня ночью", + "tomorrow": "Завтра" + }, + "weather_descriptions": { + "0": "Ясно", + "1": "Преим.ясно", + "2": "Перем.облач.", + "3": "Пасмурно", + "45": "Туман", + "48": "Туман", + "51": "Лёгк.морось", + "53": "Морось", + "55": "Сильн.морось", + "56": "Лёгк.лед.морось", + "57": "Лед.морось", + "61": "Небол.дождь", + "63": "Дождь", + "65": "Сильн.дождь", + "66": "Слаб.лед.дождь", + "67": "Лед.дождь", + "71": "Небол.снег", + "73": "Снег", + "75": "Сильн.снег", + "77": "Снеж.зерна", + "80": "Небол.ливень", + "81": "Ливень", + "82": "Сильн.ливень", + "85": "Слаб.снеж.ливень", + "86": "Снеж.ливень", + "95": "Гроза", + "96": "Гроза+град", + "99": "Сильн.гроза", + "unknown": "Неизвестно" + }, "warnings": { "extreme_heat": "⚠️ Экстремальная жара", "extreme_cold": "⚠️ Экстремальный холод", @@ -161,7 +309,8 @@ "thunderstorms": "⚠️ Грозы", "heavy_snow": "⚠️ Сильный снегопад", "high_winds": "⚠️ Сильный ветер ({wind_speed} м/ч)" - } + }, + "pressure_unit": "mmhg" }, "sports": { "description": "Получить спортивные результаты (использование: sports [команда/лига])", @@ -192,10 +341,22 @@ "description": "Статистика за 24 часа. 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'.", "help": "Статистика за 24ч. 'stats' (базовая), 'stats messages', 'stats channels', 'stats paths', 'stats adverts'", "subcommands": [ - {"name": "messages", "description": "Статистика сообщений пользователей"}, - {"name": "channels", "description": "Активность каналов"}, - {"name": "paths", "description": "Самые длинные маршруты"}, - {"name": "adverts", "description": "Топ узлов по advert-пакетам"} + { + "name": "messages", + "description": "Статистика сообщений пользователей" + }, + { + "name": "channels", + "description": "Активность каналов" + }, + { + "name": "paths", + "description": "Самые длинные маршруты" + }, + { + "name": "adverts", + "description": "Топ узлов по advert-пакетам" + } ], "disabled": "Команда stats отключена", "unknown_subcommand": "Неизвестно: {subcommand}. Используйте 'stats', 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'", @@ -338,7 +499,12 @@ "airplanes": { "description": "Самолёты над головой", "usage": "Использование: airplanes [локация] [опции]", - "subcommands": [{"name": "overhead", "description": "Ближайший самолёт над головой"}], + "subcommands": [ + { + "name": "overhead", + "description": "Ближайший самолёт над головой" + } + ], "no_location": "Нет локации. Укажите координаты: airplanes 47.6,-122.3", "overhead_no_location": "Локация неизвестна. Отправьте 'advert' или укажите координаты.", "no_aircraft": "Самолёты в радиусе {radius}мм не найдены", @@ -513,7 +679,12 @@ "channels": { "description": "Список каналов", "help": "Список каналов. 'channels' - основные, 'channels list' - по категориям.", - "subcommands": [{"name": "list", "description": "Список каналов в категории"}], + "subcommands": [ + { + "name": "list", + "description": "Список каналов в категории" + } + ], "no_channels_for_category": "Каналы для '{category}' не настроены. Используйте 'channels'.", "no_channels_configured": "Каналы не настроены. Обратитесь к администратору.", "error_retrieving_channels": "Ошибка получения каналов: {error}", @@ -605,23 +776,84 @@ "temp_low_label": "Н", "date_time": { "day_abbreviations": { - "Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт", "Fri": "Пт", "Sat": "Сб", "Sun": "Вс" + "Mon": "Пн", + "Tue": "Вт", + "Wed": "Ср", + "Thu": "Чт", + "Fri": "Пт", + "Sat": "Сб", + "Sun": "Вс" }, "days": { - "Monday": "Понедельник", "Tuesday": "Вторник", "Wednesday": "Среда", - "Thursday": "Четверг", "Friday": "Пятница", "Saturday": "Суббота", "Sunday": "Воскресенье" + "Monday": "Понедельник", + "Tuesday": "Вторник", + "Wednesday": "Среда", + "Thursday": "Четверг", + "Friday": "Пятница", + "Saturday": "Суббота", + "Sunday": "Воскресенье" }, "month_abbreviations": { - "Jan": "янв", "Feb": "фев", "Mar": "мар", "Apr": "апр", - "May": "май", "Jun": "июн", "Jul": "июл", "Aug": "авг", - "Sep": "сен", "Oct": "окт", "Nov": "ноя", "Dec": "дек" + "Jan": "янв", + "Feb": "фев", + "Mar": "мар", + "Apr": "апр", + "May": "май", + "Jun": "июн", + "Jul": "июл", + "Aug": "авг", + "Sep": "сен", + "Oct": "окт", + "Nov": "ноя", + "Dec": "дек" }, "months": { - "January": "Январь", "February": "Февраль", "March": "Март", - "April": "Апрель", "May": "Май", "June": "Июнь", - "July": "Июль", "August": "Август", "September": "Сентябрь", - "October": "Октябрь", "November": "Ноябрь", "December": "Декабрь" + "January": "Январь", + "February": "Февраль", + "March": "Март", + "April": "Апрель", + "May": "Май", + "June": "Июнь", + "July": "Июль", + "August": "Август", + "September": "Сентябрь", + "October": "Октябрь", + "November": "Ноябрь", + "December": "Декабрь" } + }, + "alerts": { + "event_types": { + "Warning": "Предупр.", + "Watch": "Наблюд.", + "Advisory": "Реком.", + "Statement": "Заявл." + }, + "til": "до", + "by": "от", + "from": "с", + "am": "утра", + "pm": "дня", + "time_12h": "{hour} {meridiem}", + "date_12h": "{month} {day} {time}" + }, + "wind_directions": { + "N": "С", + "NNE": "ССВ", + "NE": "СВ", + "ENE": "ВСВ", + "E": "В", + "ESE": "ВЮВ", + "SE": "ЮВ", + "SSE": "ЮЮВ", + "S": "Ю", + "SSW": "ЮЮЗ", + "SW": "ЮЗ", + "WSW": "ЗЮЗ", + "W": "З", + "WNW": "ЗСЗ", + "NW": "СЗ", + "NNW": "ССЗ" } }, "categories": { @@ -689,39 +921,6 @@ "96": "Гроза+град", "99": "Сильн.гроза" }, - "event_types": { - "Warning": "Предупреждение", - "Watch": "Наблюдение", - "Advisory": "Рекомендация", - "Statement": "Заявление" - }, - "months": { - "Jan": "янв", "Feb": "фев", "Mar": "мар", "Apr": "апр", - "May": "май", "Jun": "июн", "Jul": "июл", "Aug": "авг", - "Sep": "сен", "Oct": "окт", "Nov": "ноя", "Dec": "дек" - }, - "am": "утра", - "pm": "дня", - "til": "до", - "by": "от", - "wind_directions": { - "N": "С", - "NNE": "ССВ", - "NE": "СВ", - "ENE": "ВСВ", - "E": "В", - "ESE": "ВЮВ", - "SE": "ЮВ", - "SSE": "ЮЮВ", - "S": "Ю", - "SSW": "ЮЮЗ", - "SW": "ЮЗ", - "WSW": "ЗЮЗ", - "W": "З", - "WNW": "ЗСЗ", - "NW": "СЗ", - "NNW": "ССЗ" - }, "wind_speed_units": { "ms": "м/с", "mph": "mph",