From 595f37a27b09db44b3df11e21592fec623a755ea Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 7 Sep 2026 15:13:18 -0700 Subject: [PATCH] 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",