Files
meshcore-bot/modules/alert_format.py
agessaman 595f37a27b 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.
2026-09-07 15:13:18 -07:00

512 lines
18 KiB
Python

#!/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]