mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-26 17:37:53 +00:00
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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) == ""
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user