fix(i18n): address review findings on weather localization

- 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.
This commit is contained in:
agessaman
2026-09-07 15:13:18 -07:00
parent dec68d020d
commit 595f37a27b
14 changed files with 1792 additions and 859 deletions
+94
View File
@@ -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
+10 -3
View File
@@ -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}
+511
View File
@@ -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 '<event> <type-abbrev>', 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 '<event> <type-abbrev>' 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]
@@ -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:
+16 -2
View File
@@ -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
+37 -356
View File
@@ -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
+52 -231
View File
@@ -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.
+1
View File
@@ -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",
+258
View File
@@ -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) == ""
+94
View File
@@ -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
@@ -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.")]
@@ -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"
+71 -78
View File
@@ -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 <location> - Weather for any location worldwide (city, country, or coordinates)",
"usage": "Usage: gwx <location> - 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 <location> - Get weather for any global location (city, country, or coordinates)",
"usage": "Usage: gwx <location> - 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",
+363 -164
View File
@@ -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",