mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 06:12:19 +00:00
Merge pull request #261 from custardpy/feat/weather-service-localization
feat(i18n): localize weather outputs and add Russian translations
This commit is contained in:
17 files changed
+2557
-669
No files matched your search
+110
@@ -6,8 +6,118 @@ semantic versioning.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Localized proactive weather messages (daily forecasts, rain nowcasts, weather
|
||||
alerts) via `services.weather_service.*` translation keys. `WeatherService` now
|
||||
uses the bot's `translator` instead of hardcoded English strings, so proactive
|
||||
outputs respect the configured `language` setting — the same mechanism already
|
||||
used by `!wx`, `!gwx`, `!rain` and other commands.
|
||||
|
||||
- Russian (`ru`) translation for the full bot UI, including all weather service
|
||||
keys, command keys, categories, and common strings.
|
||||
|
||||
- `format_temperature_high_low()` now accepts an optional `translator` parameter.
|
||||
When provided, the `H`/`L` temperature labels and compass wind directions are
|
||||
locale-aware (`H`/`L` → `В`/`Н` in Russian; `WNW` → `ЗСЗ`). Passed from
|
||||
`WeatherService`, `!wx`, and `!gwx` callers.
|
||||
|
||||
- `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]
|
||||
@@ -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,9 +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)
|
||||
return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger,
|
||||
translator=self.response_translator)
|
||||
|
||||
def _load_weather_model(self) -> Optional[str]:
|
||||
"""Load and normalize Open-Meteo model selection from config.
|
||||
@@ -1059,16 +1077,19 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
# Add feels like if significantly different
|
||||
if abs(feels_like - temp) >= 5:
|
||||
weather += f" (feels {feels_like}{temp_symbol})"
|
||||
feels_str = self.translate('commands.gwx.feels_like', value=feels_like, unit=temp_symbol)
|
||||
weather += f" {feels_str}"
|
||||
|
||||
# Add wind info (always show if >= 3 mph, show gusts if significant)
|
||||
if wind_speed >= 3:
|
||||
weather += f" {wind_direction}{wind_speed}"
|
||||
if wind_gusts > wind_speed + 3:
|
||||
weather += f"G{wind_gusts}"
|
||||
gust_str = self.translate('commands.gwx.gust', value=wind_gusts)
|
||||
weather += gust_str
|
||||
|
||||
# Add humidity
|
||||
weather += f" {humidity}%RH"
|
||||
humidity_str = self.translate('commands.gwx.humidity', value=humidity)
|
||||
weather += f" {humidity_str}"
|
||||
|
||||
# Add additional conditions if space allows
|
||||
conditions = []
|
||||
@@ -1076,25 +1097,38 @@ class GlobalWxCommand(BaseCommand):
|
||||
# Add dew point
|
||||
if dewpoint is not None:
|
||||
dewpoint_val = int(dewpoint)
|
||||
conditions.append(f"💧{dewpoint_val}{temp_symbol}")
|
||||
dew_str = self.translate('commands.gwx.dew_point', value=dewpoint_val, unit=temp_symbol)
|
||||
conditions.append(dew_str)
|
||||
|
||||
# Add visibility (already converted to miles above)
|
||||
if visibility_mi is not None and visibility_mi > 0:
|
||||
# Cap visibility at 20 miles for display (beyond that is essentially unlimited)
|
||||
visibility_display = int(visibility_mi)
|
||||
if visibility_display > 20:
|
||||
visibility_display = 20
|
||||
conditions.append(f"👁️{visibility_display}mi")
|
||||
# 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:
|
||||
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)
|
||||
conditions.append(f"📊{pressure_hpa}hPa")
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
@@ -1181,7 +1215,8 @@ class GlobalWxCommand(BaseCommand):
|
||||
if len(daily.get('wind_gusts_10m_max', [])) > 1:
|
||||
wind_gusts = int(daily['wind_gusts_10m_max'][1])
|
||||
if wind_gusts > wind_speed + 3:
|
||||
wind_info += f"G{wind_gusts}"
|
||||
gust_str = self.translate('commands.gwx.gust', value=wind_gusts)
|
||||
wind_info += gust_str
|
||||
|
||||
# Get precipitation probability and amount
|
||||
precip_info = ""
|
||||
@@ -1235,13 +1270,13 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
# Map day names to 1-2 letter abbreviations
|
||||
day_abbrev_map = {
|
||||
'Monday': 'M',
|
||||
'Tuesday': 'T',
|
||||
'Wednesday': 'W',
|
||||
'Thursday': 'Th',
|
||||
'Friday': 'F',
|
||||
'Saturday': 'Sa',
|
||||
'Sunday': 'Su'
|
||||
'Monday': self.translate('commands.gwx.day_abbrev.Monday'),
|
||||
'Tuesday': self.translate('commands.gwx.day_abbrev.Tuesday'),
|
||||
'Wednesday': self.translate('commands.gwx.day_abbrev.Wednesday'),
|
||||
'Thursday': self.translate('commands.gwx.day_abbrev.Thursday'),
|
||||
'Friday': self.translate('commands.gwx.day_abbrev.Friday'),
|
||||
'Saturday': self.translate('commands.gwx.day_abbrev.Saturday'),
|
||||
'Sunday': self.translate('commands.gwx.day_abbrev.Sunday')
|
||||
}
|
||||
|
||||
parts = []
|
||||
@@ -1364,20 +1399,24 @@ class GlobalWxCommand(BaseCommand):
|
||||
if degrees is None:
|
||||
return ""
|
||||
|
||||
directions = [
|
||||
(0, "⬆️N"), (22.5, "↗️NE"), (45, "↗️NE"), (67.5, "➡️E"),
|
||||
(90, "➡️E"), (112.5, "↘️SE"), (135, "↘️SE"), (157.5, "⬇️S"),
|
||||
(180, "⬇️S"), (202.5, "↙️SW"), (225, "↙️SW"), (247.5, "⬅️W"),
|
||||
(270, "⬅️W"), (292.5, "↖️NW"), (315, "↖️NW"), (337.5, "⬆️N"),
|
||||
(360, "⬆️N")
|
||||
dir_emojis = [
|
||||
(0, "⬆️", "N"), (22.5, "↗️", "NE"), (45, "↗️", "NE"), (67.5, "➡️", "E"),
|
||||
(90, "➡️", "E"), (112.5, "↘️", "SE"), (135, "↘️", "SE"), (157.5, "⬇️", "S"),
|
||||
(180, "⬇️", "S"), (202.5, "↙️", "SW"), (225, "↙️", "SW"), (247.5, "⬅️", "W"),
|
||||
(270, "⬅️", "W"), (292.5, "↖️", "NW"), (315, "↖️", "NW"), (337.5, "⬆️", "N"),
|
||||
(360, "⬆️", "N")
|
||||
]
|
||||
|
||||
# Find closest direction
|
||||
for i in range(len(directions) - 1):
|
||||
if directions[i][0] <= degrees < directions[i + 1][0]:
|
||||
return directions[i][1]
|
||||
for i in range(len(dir_emojis) - 1):
|
||||
if dir_emojis[i][0] <= degrees < dir_emojis[i + 1][0]:
|
||||
emoji, key = dir_emojis[i][1], dir_emojis[i][2]
|
||||
translated = self.translate(f"common.wind_directions.{key}")
|
||||
return f"{emoji}{translated}"
|
||||
|
||||
return "⬆️N" # Default to North
|
||||
emoji, key = dir_emojis[-1][1], dir_emojis[-1][2]
|
||||
translated = self.translate(f"common.wind_directions.{key}")
|
||||
return f"{emoji}{translated}"
|
||||
|
||||
def _get_weather_description(self, code: int) -> str:
|
||||
"""Convert WMO weather code to description.
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
-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,
|
||||
@@ -177,7 +178,8 @@ class WxCommand(BaseCommand):
|
||||
|
||||
def _format_high_low(self, high: Optional[float], low: Optional[float], temp_symbol: str) -> str:
|
||||
"""Format high/low using [Weather] temperature_*_format templates."""
|
||||
return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger)
|
||||
return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger,
|
||||
translator=self.response_translator)
|
||||
|
||||
@staticmethod
|
||||
def _noaa_period_temp_symbol(period: dict) -> str:
|
||||
@@ -2639,120 +2641,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
|
||||
@@ -2786,27 +2692,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}")
|
||||
@@ -2883,96 +2776,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)
|
||||
|
||||
@@ -3102,155 +2927,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"""
|
||||
@@ -3347,7 +3029,8 @@ class WxCommand(BaseCommand):
|
||||
low_val = int(low)
|
||||
if _pair_ok(high_val, low_val):
|
||||
return format_temperature_high_low(
|
||||
self.bot.config, high_val, low_val, units_str, self.logger
|
||||
self.bot.config, high_val, low_val, units_str, self.logger,
|
||||
translator=self.response_translator,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -3358,7 +3041,8 @@ class WxCommand(BaseCommand):
|
||||
low_val = int(low_match.group(1))
|
||||
if _single_ok(low_val):
|
||||
return format_temperature_high_low(
|
||||
self.bot.config, None, low_val, units_str, self.logger
|
||||
self.bot.config, None, low_val, units_str, self.logger,
|
||||
translator=self.response_translator,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -3369,7 +3053,8 @@ class WxCommand(BaseCommand):
|
||||
high_val = int(high_match.group(1))
|
||||
if _single_ok(high_val):
|
||||
return format_temperature_high_low(
|
||||
self.bot.config, high_val, None, units_str, self.logger
|
||||
self.bot.config, high_val, None, units_str, self.logger,
|
||||
translator=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.
|
||||
@@ -198,6 +210,21 @@ class WeatherService(BaseServicePlugin):
|
||||
|
||||
self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}")
|
||||
|
||||
def _translate(self, key: str, **kwargs: Any) -> str:
|
||||
"""Translate a key using the bot's translator.
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., 'services.weather_service.daily_weather').
|
||||
**kwargs: Formatting parameters for str.format().
|
||||
|
||||
Returns:
|
||||
Translated string, or key if translation not found.
|
||||
"""
|
||||
translator = getattr(self.bot, 'translator', None)
|
||||
if translator is not None:
|
||||
return translator.translate(key, **kwargs)
|
||||
return key
|
||||
|
||||
def _load_weather_model(self) -> Optional[str]:
|
||||
"""Load and normalize Open-Meteo model selection from config.
|
||||
|
||||
@@ -518,11 +545,13 @@ class WeatherService(BaseServicePlugin):
|
||||
# Get weather forecast
|
||||
forecast_text = await self._get_weather_forecast()
|
||||
|
||||
if forecast_text and forecast_text != "Error fetching weather data":
|
||||
error_label = self._translate('services.weather_service.error_fetching')
|
||||
if forecast_text and forecast_text != error_label:
|
||||
# Send to configured channel
|
||||
daily_label = self._translate('services.weather_service.daily_weather')
|
||||
await self.bot.command_manager.send_channel_message(
|
||||
self.weather_channel,
|
||||
f"🌤️ Daily Weather: {forecast_text}",
|
||||
f"🌤️ {daily_label}: {forecast_text}",
|
||||
scope=self.get_mesh_flood_scope(),
|
||||
)
|
||||
self.logger.info(f"Daily weather forecast sent to {self.weather_channel}")
|
||||
@@ -564,17 +593,17 @@ class WeatherService(BaseServicePlugin):
|
||||
)
|
||||
if not response.ok:
|
||||
self.logger.warning(f"Error fetching weather from Open-Meteo: HTTP {response.status_code}")
|
||||
return "Error fetching weather data"
|
||||
return self._translate('services.weather_service.error_fetching')
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
self.logger.warning(f"Timeout/connection error fetching weather: {e}")
|
||||
return "Error fetching weather data"
|
||||
return self._translate('services.weather_service.error_fetching')
|
||||
|
||||
# Extract current conditions
|
||||
current = data.get('current', {})
|
||||
daily = data.get('daily', {})
|
||||
|
||||
if not current or not daily:
|
||||
return "No forecast data available"
|
||||
return self._translate('services.weather_service.no_data')
|
||||
|
||||
# Current conditions
|
||||
temp = int(current.get('temperature_2m', 0))
|
||||
@@ -625,15 +654,20 @@ 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 ""
|
||||
forecast_text += f" {wind_dir_str}{wind_speed}{self.wind_speed_unit}"
|
||||
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])
|
||||
forecast_text += (
|
||||
" | "
|
||||
+ format_temperature_high_low(
|
||||
self.bot.config, today_high, today_low, temp_symbol, self.logger
|
||||
self.bot.config, today_high, today_low, temp_symbol, self.logger,
|
||||
translator=getattr(self.bot, 'translator', None),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -651,6 +685,7 @@ class WeatherService(BaseServicePlugin):
|
||||
tomorrow_emoji = self._get_weather_emoji(tomorrow_code)
|
||||
|
||||
if tomorrow_max is not None:
|
||||
tomorrow_label = self._translate('services.weather_service.tomorrow')
|
||||
if tomorrow_min is not None and tomorrow_min != tomorrow_max:
|
||||
hl = format_temperature_high_low(
|
||||
self.bot.config,
|
||||
@@ -658,8 +693,9 @@ class WeatherService(BaseServicePlugin):
|
||||
tomorrow_min,
|
||||
temp_symbol,
|
||||
self.logger,
|
||||
translator=getattr(self.bot, 'translator', None),
|
||||
)
|
||||
forecast_text += f" | Tomorrow: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
||||
forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
||||
else:
|
||||
hl = format_temperature_high_low(
|
||||
self.bot.config,
|
||||
@@ -667,8 +703,9 @@ class WeatherService(BaseServicePlugin):
|
||||
None,
|
||||
temp_symbol,
|
||||
self.logger,
|
||||
translator=getattr(self.bot, 'translator', None),
|
||||
)
|
||||
forecast_text += f" | Tomorrow: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
||||
forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
||||
|
||||
return forecast_text
|
||||
|
||||
@@ -676,7 +713,7 @@ class WeatherService(BaseServicePlugin):
|
||||
self.logger.error(f"Error getting weather forecast: {e}")
|
||||
import traceback
|
||||
self.logger.debug(traceback.format_exc())
|
||||
return "Error fetching weather data"
|
||||
return self._translate('services.weather_service.error_fetching')
|
||||
|
||||
def _degrees_to_direction(self, degrees: float) -> str:
|
||||
"""Convert wind direction in degrees to compass direction.
|
||||
@@ -685,7 +722,7 @@ class WeatherService(BaseServicePlugin):
|
||||
degrees: Wind direction in degrees (0-360).
|
||||
|
||||
Returns:
|
||||
str: Compass direction (e.g., 'N', 'NE', 'SW').
|
||||
str: Compass direction (e.g., 'N', 'NE', 'SW') in the current language.
|
||||
"""
|
||||
if degrees is None:
|
||||
return ""
|
||||
@@ -693,7 +730,10 @@ class WeatherService(BaseServicePlugin):
|
||||
directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
index = int((degrees + 11.25) / 22.5) % 16
|
||||
return directions[index]
|
||||
key = directions[index]
|
||||
return 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.
|
||||
@@ -702,22 +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.
|
||||
"""
|
||||
# WMO Weather interpretation codes (WW)
|
||||
codes = {
|
||||
0: "Clear", 1: "Mostly Clear", 2: "Partly Cloudy", 3: "Overcast",
|
||||
45: "Foggy", 48: "Depositing Rime Fog",
|
||||
51: "Light Drizzle", 53: "Moderate Drizzle", 55: "Dense Drizzle",
|
||||
56: "Light Freezing Drizzle", 57: "Dense Freezing Drizzle",
|
||||
61: "Slight Rain", 63: "Moderate Rain", 65: "Heavy Rain",
|
||||
66: "Light Freezing Rain", 67: "Heavy Freezing Rain",
|
||||
71: "Slight Snow", 73: "Moderate Snow", 75: "Heavy Snow",
|
||||
77: "Snow Grains", 80: "Slight Rain Showers", 81: "Moderate Rain Showers",
|
||||
82: "Violent Rain Showers", 85: "Slight Snow Showers", 86: "Heavy Snow Showers",
|
||||
95: "Thunderstorm", 96: "Thunderstorm w/Hail", 99: "Severe Thunderstorm"
|
||||
}
|
||||
return codes.get(code, "Unknown")
|
||||
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.
|
||||
@@ -1005,7 +1037,8 @@ class WeatherService(BaseServicePlugin):
|
||||
kind is "starting" (rain incoming) or "ending" (rain about to stop).
|
||||
prob/temp_f add a probability and a borderline-temperature tag.
|
||||
"""
|
||||
emoji, ptype = precip_descriptor(result.bucket)
|
||||
emoji, _ptype_en = precip_descriptor(result.bucket)
|
||||
ptype = self._translate(f'commands.rain.precip_types.{result.bucket or "rain"}')
|
||||
|
||||
# City + state/country (same labeling as the !rain command), reverse-
|
||||
# geocoded once and cached. Kept separate from the daily-forecast cache.
|
||||
@@ -1018,7 +1051,8 @@ class WeatherService(BaseServicePlugin):
|
||||
),
|
||||
)
|
||||
self._cached_rain_location = join_location(city, suffix)
|
||||
location = f" near {self._cached_rain_location}" if self._cached_rain_location else ""
|
||||
near_label = self._translate('services.weather_service.near')
|
||||
location = f" {near_label} {self._cached_rain_location}" if self._cached_rain_location else ""
|
||||
|
||||
parts = []
|
||||
if self.rain_nowcast_show_amount:
|
||||
@@ -1026,17 +1060,24 @@ class WeatherService(BaseServicePlugin):
|
||||
result.bucket, result.amount_mm, result.snow_cm, self.rain_nowcast_amount_unit
|
||||
)
|
||||
if amt:
|
||||
parts.append(f"est {amt}")
|
||||
est_label = self._translate('services.weather_service.est')
|
||||
parts.append(f"{est_label} {amt}")
|
||||
if prob is not None:
|
||||
parts.append(f"{prob}%")
|
||||
est = f" ({', '.join(parts)})" if parts else ""
|
||||
temp = f" {temp_f}°F" if (temp_f is not None and 30 <= temp_f <= 38) else ""
|
||||
if kind == "ending":
|
||||
return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{est}{temp}{location}"
|
||||
return self._translate(
|
||||
'services.weather_service.rain_ending',
|
||||
emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, temp=temp, location=location,
|
||||
)
|
||||
# Flag prolonged rain ("steady") rather than a numeric duration, which
|
||||
# would sit confusingly next to the minutes-until-start value.
|
||||
steady = " (steady)" if result.open_ended else ""
|
||||
return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{est}{steady}{temp}{location}"
|
||||
steady_label = f" ({self._translate('services.weather_service.steady')})" if result.open_ended else ""
|
||||
return self._translate(
|
||||
'services.weather_service.rain_starting',
|
||||
emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, steady=steady_label, temp=temp, location=location,
|
||||
)
|
||||
|
||||
async def _connect_blitzortung_mqtt(self) -> None:
|
||||
"""Connect to Blitzortung MQTT broker and subscribe to lightning data.
|
||||
@@ -1588,243 +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 = {
|
||||
'Warning': 'Warn',
|
||||
'Watch': 'Watch',
|
||||
'Advisory': 'Adv',
|
||||
'Statement': 'Stmt'
|
||||
}.get(event_type, event_type)
|
||||
|
||||
# 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:
|
||||
expires_compact = self._compact_time(expires)
|
||||
if any(month in expires_compact for month in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]):
|
||||
time_match = re.search(r'(\d+)(AM|PM)', expires_compact, re.IGNORECASE)
|
||||
if time_match:
|
||||
hour = time_match.group(1)
|
||||
am_pm = time_match.group(2)
|
||||
expires_short = f" til {hour}{am_pm}"
|
||||
else:
|
||||
expires_short = f" til {expires_compact[:15]}"
|
||||
else:
|
||||
time_match = re.search(r'(\d+):?(\d+)?(AM|PM)', expires_compact, re.IGNORECASE)
|
||||
if time_match:
|
||||
hour = time_match.group(1)
|
||||
am_pm = time_match.group(3)
|
||||
expires_short = f" til {hour}{am_pm}"
|
||||
else:
|
||||
expires_short = f" til {expires_compact[:15]}"
|
||||
result += expires_short
|
||||
|
||||
# Add office if available (abbreviate city name)
|
||||
if office:
|
||||
office_parts = office.split()
|
||||
if len(office_parts) >= 2:
|
||||
office_org = office_parts[0]
|
||||
city = office_parts[1] if len(office_parts) > 1 else ""
|
||||
city_abbrev = self._abbreviate_city_name(city)
|
||||
office_short = f" by {office_org} {city_abbrev}"
|
||||
else:
|
||||
office_short = f" by {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 + " 🔗"
|
||||
|
||||
result = alert_format.format_alert_compact(
|
||||
alert, getattr(self.bot, 'translator', None), include_details=include_details
|
||||
)
|
||||
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_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
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
# Remove leading zeros from hours
|
||||
time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str)
|
||||
|
||||
# Abbreviate month names
|
||||
month_abbrev_map = {
|
||||
"January": "Jan", "February": "Feb", "March": "Mar", "April": "Apr",
|
||||
"May": "May", "June": "Jun", "July": "Jul", "August": "Aug",
|
||||
"September": "Sep", "October": "Oct", "November": "Nov", "December": "Dec"
|
||||
}
|
||||
for full, abbrev in month_abbrev_map.items():
|
||||
time_str = time_str.replace(full, abbrev)
|
||||
|
||||
# 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.
|
||||
|
||||
+22
-12
@@ -114,18 +114,26 @@ def format_temperature_high_low(
|
||||
low: Optional[Union[int, float]],
|
||||
units_str: str,
|
||||
logger: Optional[Any] = None,
|
||||
translator: Any = None,
|
||||
) -> str:
|
||||
"""Format a daily high/low pair (or single value) using [Weather] templates.
|
||||
|
||||
Config keys (optional; defaults match prior bot behavior):
|
||||
temperature_high_low_format — both values: {high}, {low}, {units}
|
||||
temperature_high_only_format — {high}, {units}
|
||||
temperature_low_only_format — {low}, {units}
|
||||
temperature_high_low_format — both values: {high}, {low}, {units}, {high_label}, {low_label}
|
||||
temperature_high_only_format — {high}, {units}, {high_label}
|
||||
temperature_low_only_format — {low}, {units}, {low_label}
|
||||
"""
|
||||
if translator is not None:
|
||||
high_label = translator.translate("common.temp_high_label")
|
||||
low_label = translator.translate("common.temp_low_label")
|
||||
else:
|
||||
high_label = "H"
|
||||
low_label = "L"
|
||||
|
||||
section = "Weather"
|
||||
default_pair = "H:{high}{units} L:{low}{units}"
|
||||
default_high_only = "H:{high}{units}"
|
||||
default_low_only = "L:{low}{units}"
|
||||
default_pair = f"{high_label}:{{high}}{{units}} {low_label}:{{low}}{{units}}"
|
||||
default_high_only = f"{high_label}:{{high}}{{units}}"
|
||||
default_low_only = f"{low_label}:{{low}}{{units}}"
|
||||
|
||||
def _norm(v: Optional[Union[int, float]]) -> Optional[int]:
|
||||
if v is None:
|
||||
@@ -157,22 +165,24 @@ def format_temperature_high_low(
|
||||
logger.warning("Invalid temperature format template %r: %s", fmt, e)
|
||||
return None
|
||||
|
||||
labels = {"high_label": high_label, "low_label": low_label}
|
||||
|
||||
if hi is not None and lo is not None:
|
||||
out = _try_format(pair_fmt, high=hi, low=lo, units=units_str)
|
||||
out = _try_format(pair_fmt, high=hi, low=lo, units=units_str, **labels)
|
||||
if out is not None:
|
||||
return out
|
||||
return _try_format(default_pair, high=hi, low=lo, units=units_str) or f"H:{hi}{units_str} L:{lo}{units_str}"
|
||||
return _try_format(default_pair, high=hi, low=lo, units=units_str, **labels) or f"{high_label}:{hi}{units_str} {low_label}:{lo}{units_str}"
|
||||
|
||||
if hi is not None:
|
||||
out = _try_format(high_only_fmt, high=hi, low=lo, units=units_str)
|
||||
out = _try_format(high_only_fmt, high=hi, low=lo, units=units_str, **labels)
|
||||
if out is not None:
|
||||
return out
|
||||
return _try_format(default_high_only, high=hi, low=lo, units=units_str) or f"H:{hi}{units_str}"
|
||||
return _try_format(default_high_only, high=hi, low=lo, units=units_str, **labels) or f"{high_label}:{hi}{units_str}"
|
||||
|
||||
out = _try_format(low_only_fmt, high=hi, low=lo, units=units_str)
|
||||
out = _try_format(low_only_fmt, high=hi, low=lo, units=units_str, **labels)
|
||||
if out is not None:
|
||||
return out
|
||||
return _try_format(default_low_only, high=hi, low=lo, units=units_str) or f"L:{lo}{units_str}"
|
||||
return _try_format(default_low_only, high=hi, low=lo, units=units_str, **labels) or f"{low_label}:{lo}{units_str}"
|
||||
|
||||
|
||||
def abbreviate_location(location: str, max_length: int = 20) -> str:
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ import asyncio
|
||||
import configparser
|
||||
from unittest.mock import Mock
|
||||
|
||||
from modules.i18n import Translator
|
||||
from modules.service_plugins.weather_service import WeatherService
|
||||
from tests.unit._rain_harness import make_series
|
||||
|
||||
@@ -47,6 +48,7 @@ def build_service(series, monkeypatch, *, overrides=None):
|
||||
bot.logger = Mock()
|
||||
bot.config = cfg
|
||||
bot.db_manager = Mock()
|
||||
bot.translator = Translator("en", "translations/")
|
||||
|
||||
sends: list[tuple[str, str]] = []
|
||||
|
||||
@@ -116,6 +118,7 @@ def test_nws_path_accepts_cache_ttl_without_crashing(monkeypatch):
|
||||
bot.logger = Mock()
|
||||
bot.config = cfg
|
||||
bot.db_manager = Mock()
|
||||
bot.translator = Translator("en", "translations/")
|
||||
|
||||
sends: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
"""Unit tests for format_temperature_high_low ([Weather] templates)."""
|
||||
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.i18n import Translator
|
||||
from modules.utils import format_temperature_high_low
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TRANSLATIONS = str(REPO_ROOT / "translations")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg():
|
||||
@@ -53,3 +58,34 @@ def test_bad_template_falls_back(mock_logger):
|
||||
out = format_temperature_high_low(c, 10, 5, "°C", mock_logger)
|
||||
assert out == "H:10°C L:5°C"
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
|
||||
def test_translator_en_labels(cfg):
|
||||
translator = Translator(language="en", translation_path=TRANSLATIONS)
|
||||
assert (
|
||||
format_temperature_high_low(cfg, 47, 33, "°F", None, translator=translator)
|
||||
== "H:47°F L:33°F"
|
||||
)
|
||||
|
||||
|
||||
def test_translator_ru_labels(cfg):
|
||||
translator = Translator(language="ru", translation_path=TRANSLATIONS)
|
||||
assert (
|
||||
format_temperature_high_low(cfg, 47, 33, "°C", None, translator=translator)
|
||||
== "В:47°C Н:33°C"
|
||||
)
|
||||
|
||||
|
||||
def test_translator_ru_high_only(cfg):
|
||||
translator = Translator(language="ru", translation_path=TRANSLATIONS)
|
||||
assert format_temperature_high_low(cfg, 46, None, "°C", None, translator=translator) == "В:46°C"
|
||||
assert format_temperature_high_low(cfg, None, 38, "°C", None, translator=translator) == "Н:38°C"
|
||||
|
||||
|
||||
def test_translator_uses_custom_template(cfg):
|
||||
translator = Translator(language="ru", translation_path=TRANSLATIONS)
|
||||
cfg.set("Weather", "temperature_high_low_format", "{high_label}{high}° / {low_label}{low}°")
|
||||
assert (
|
||||
format_temperature_high_low(cfg, 47, 33, "°C", None, translator=translator)
|
||||
== "В47° / Н33°"
|
||||
)
|
||||
@@ -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"
|
||||
+102
-1
@@ -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",
|
||||
@@ -249,6 +250,23 @@
|
||||
"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",
|
||||
@@ -292,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])",
|
||||
@@ -1357,6 +1376,8 @@
|
||||
"no_help_available": "No help available for this command.",
|
||||
"snr_label": "SNR: {value} dB",
|
||||
"rssi_label": "RSSI: {value} dBm",
|
||||
"temp_high_label": "H",
|
||||
"temp_low_label": "L",
|
||||
"date_time": {
|
||||
"day_abbreviations": {
|
||||
"Mon": "Mon",
|
||||
@@ -1404,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": {
|
||||
@@ -1430,6 +1484,53 @@
|
||||
"sender": "Sender: {sender}",
|
||||
"area": "Area: {areas}"
|
||||
}
|
||||
},
|
||||
"weather_service": {
|
||||
"daily_weather": "Daily Weather",
|
||||
"tomorrow": "Tomorrow",
|
||||
"error_fetching": "Error fetching weather data",
|
||||
"no_data": "No forecast data available",
|
||||
"near": "near",
|
||||
"est": "est",
|
||||
"steady": "steady",
|
||||
"rain_starting": "{emoji} Heads up — {ptype} starting in ~{minutes}min{est}{steady}{temp}{location}",
|
||||
"rain_ending": "{emoji} Heads up — {ptype} ending in ~{minutes}min{est}{temp}{location}",
|
||||
"weather_descriptions": {
|
||||
"0": "Clear",
|
||||
"1": "Mostly Clear",
|
||||
"2": "Partly Cloudy",
|
||||
"3": "Overcast",
|
||||
"45": "Foggy",
|
||||
"48": "Depositing Rime Fog",
|
||||
"51": "Light Drizzle",
|
||||
"53": "Moderate Drizzle",
|
||||
"55": "Dense Drizzle",
|
||||
"56": "Light Freezing Drizzle",
|
||||
"57": "Dense Freezing Drizzle",
|
||||
"61": "Slight Rain",
|
||||
"63": "Moderate Rain",
|
||||
"65": "Heavy Rain",
|
||||
"66": "Light Freezing Rain",
|
||||
"67": "Heavy Freezing Rain",
|
||||
"71": "Slight Snow",
|
||||
"73": "Moderate Snow",
|
||||
"75": "Heavy Snow",
|
||||
"77": "Snow Grains",
|
||||
"80": "Slight Rain Showers",
|
||||
"81": "Moderate Rain Showers",
|
||||
"82": "Violent Rain Showers",
|
||||
"85": "Slight Snow Showers",
|
||||
"86": "Heavy Snow Showers",
|
||||
"95": "Thunderstorm",
|
||||
"96": "Thunderstorm w/Hail",
|
||||
"99": "Severe Thunderstorm"
|
||||
},
|
||||
"wind_speed_units": {
|
||||
"ms": "m/s",
|
||||
"mph": "mph",
|
||||
"kmh": "km/h",
|
||||
"kn": "kn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"keywords": {
|
||||
"help": [
|
||||
"help"
|
||||
],
|
||||
"ping": [
|
||||
"ping"
|
||||
],
|
||||
"test": [
|
||||
"test",
|
||||
"t"
|
||||
],
|
||||
"wx": [
|
||||
"wx",
|
||||
"weather",
|
||||
"wxa",
|
||||
"wxalert"
|
||||
],
|
||||
"gwx": [
|
||||
"gwx",
|
||||
"globalweather",
|
||||
"gwxa"
|
||||
],
|
||||
"aqi": [
|
||||
"aqi",
|
||||
"air",
|
||||
"airquality",
|
||||
"air_quality"
|
||||
],
|
||||
"rain": [
|
||||
"rain",
|
||||
"nowcast",
|
||||
"snow"
|
||||
],
|
||||
"aurora": [
|
||||
"aurora",
|
||||
"kp"
|
||||
],
|
||||
"solar": [
|
||||
"solar"
|
||||
],
|
||||
"sun": [
|
||||
"sun"
|
||||
],
|
||||
"moon": [
|
||||
"moon"
|
||||
],
|
||||
"hfcond": [
|
||||
"hfcond",
|
||||
"hf"
|
||||
],
|
||||
"satpass": [
|
||||
"satpass"
|
||||
],
|
||||
"sports": [
|
||||
"sports",
|
||||
"score",
|
||||
"scores"
|
||||
],
|
||||
"worldcup": [
|
||||
"wc",
|
||||
"worldcup"
|
||||
],
|
||||
"stats": [
|
||||
"stats"
|
||||
],
|
||||
"channels": [
|
||||
"channels",
|
||||
"channel"
|
||||
],
|
||||
"path": [
|
||||
"path",
|
||||
"decode",
|
||||
"route"
|
||||
],
|
||||
"prefix": [
|
||||
"prefix",
|
||||
"repeater",
|
||||
"lookup"
|
||||
],
|
||||
"repeater": [
|
||||
"repeater",
|
||||
"repeaters",
|
||||
"rp"
|
||||
],
|
||||
"solarforecast": [
|
||||
"sf",
|
||||
"solarforecast"
|
||||
],
|
||||
"dice": [
|
||||
"dice"
|
||||
],
|
||||
"roll": [
|
||||
"roll"
|
||||
],
|
||||
"joke": [
|
||||
"joke",
|
||||
"jokes"
|
||||
],
|
||||
"dadjoke": [
|
||||
"dadjoke",
|
||||
"dad joke",
|
||||
"dadjokes",
|
||||
"dad jokes"
|
||||
],
|
||||
"catfact": [
|
||||
"catfact",
|
||||
"cat",
|
||||
"meow",
|
||||
"purr",
|
||||
"kitten"
|
||||
],
|
||||
"hacker": [
|
||||
"hacker",
|
||||
"sudo",
|
||||
"ps aux",
|
||||
"grep",
|
||||
"ls -l",
|
||||
"ls -la",
|
||||
"echo $PATH"
|
||||
],
|
||||
"hello": [
|
||||
"hello",
|
||||
"hi",
|
||||
"hey",
|
||||
"howdy",
|
||||
"greetings",
|
||||
"salutations",
|
||||
"good morning",
|
||||
"good afternoon",
|
||||
"good evening",
|
||||
"good night",
|
||||
"yo",
|
||||
"sup",
|
||||
"whats up",
|
||||
"what's up",
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
"night",
|
||||
"gday",
|
||||
"g'day",
|
||||
"hola",
|
||||
"bonjour",
|
||||
"ciao",
|
||||
"namaste",
|
||||
"aloha",
|
||||
"shalom",
|
||||
"konnichiwa",
|
||||
"guten tag",
|
||||
"buenos dias",
|
||||
"buenas tardes",
|
||||
"buenas noches"
|
||||
],
|
||||
"webviewer": [
|
||||
"webviewer",
|
||||
"web",
|
||||
"viewer",
|
||||
"wv"
|
||||
],
|
||||
"cmd": [
|
||||
"cmd",
|
||||
"commands"
|
||||
],
|
||||
"advert": [
|
||||
"advert"
|
||||
],
|
||||
"neighbors": [
|
||||
"neighbors",
|
||||
"neighbours"
|
||||
],
|
||||
"multitest": [
|
||||
"multitest",
|
||||
"mt"
|
||||
],
|
||||
"trace": [
|
||||
"trace",
|
||||
"tracer"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"help": {
|
||||
"description": "Показывает команды. Используйте 'help <command>' для подробностей.",
|
||||
"general": "Помощь MeshCore Bot\n\nДоступные команды:\n{commands_list}",
|
||||
"unknown": "Неизвестная команда: '{command}'\n\nДоступные команды:\n{available}",
|
||||
"specific": "Помощь для '{command}':\n{help_text}",
|
||||
"no_help": "Справка недоступна",
|
||||
"usage_examples": "\nПримеры:\n• `help @` - Справка по синтаксису @{string}\n• `help t` - Справка по синтаксису t {string}\n• `help advert` - Справка по команде advert\n• `help test` - Справка по команде test\n• `help ping` - Справка по команде ping",
|
||||
"custom_syntax": "\nПользовательский синтаксис:\n• `t phrase` - Подтверждение с фразой (каналы и ЛС)\n• `@{string}` - Подтверждение со строкой (только ЛС)\n• `advert` - Отправить flood advert (только ЛС, кулдаун 1 час)"
|
||||
},
|
||||
"ping": {
|
||||
"description": "Отвечает на 'ping' сообщением 'Pong!'",
|
||||
"response": "Pong!"
|
||||
},
|
||||
"test": {
|
||||
"description": "Отвечает на 'test' или 't' информацией о подключении",
|
||||
"help": "Отвечает на 'test' или 't' информацией о подключении"
|
||||
},
|
||||
"wx": {
|
||||
"description": "Получить погоду по почтовому индексу (использование: wx 12345)",
|
||||
"usage": "Использование: wx <индекс|город> - Пример: wx 12345 или wx seattle или wx paris, tx",
|
||||
"subcommands": [
|
||||
{
|
||||
"name": "tomorrow",
|
||||
"description": "Прогноз на завтра"
|
||||
},
|
||||
{
|
||||
"name": "Nd",
|
||||
"description": "Прогноз на N дней (напр. 7d, 10d)"
|
||||
},
|
||||
{
|
||||
"name": "hourly",
|
||||
"description": "Почасовой прогноз"
|
||||
},
|
||||
{
|
||||
"name": "alerts",
|
||||
"description": "Полныеweather-оповещения"
|
||||
}
|
||||
],
|
||||
"error_fetching": "Ошибка получения данных погоды из NOAA",
|
||||
"no_location_zipcode": "Не удалось найти локацию для индекса {location}",
|
||||
"no_location_city": "Не удалось найти город '{location}' в {state}",
|
||||
"error": "Ошибка получения данных погоды: {error}",
|
||||
"alerts": "{count} оповещений: {text}",
|
||||
"tomorrow_not_available": "Прогноз на завтра недоступен",
|
||||
"tomorrow_error": "Ошибка форматирования прогноза на завтра",
|
||||
"multiday_not_available": "Прогноз на {num_days} дней недоступен",
|
||||
"multiday_error": "Ошибка форматирования прогноза на {num_days} дней",
|
||||
"mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды",
|
||||
"mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен (включите [MqttWeather] и custom.mqtt_weather.* темы)",
|
||||
"mqtt_weather_no_data": "Сообщение MQTT-погоды для этой темы ещё не получено",
|
||||
"mqtt_weather_stale": "Данные MQTT-погоды слишком старые",
|
||||
"mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT-погоды: {detail}"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Получить погоду для любой точки мира (использование: gwx Tokyo)",
|
||||
"help": "Использование: gwx <локация> - Погода для любой точки мира (город, страна или координаты)",
|
||||
"usage": "Использование: gwx <локация> - Пример: gwx Tokyo или gwx Paris, France",
|
||||
"error_fetching": "Ошибка получения данных погоды",
|
||||
"error_fetching_api": "Ошибка получения данных погоды из Open-Meteo",
|
||||
"no_location": "Не удалось найти локацию '{location}'",
|
||||
"error": "Ошибка получения данных погоды: {error}",
|
||||
"tomorrow_not_available": "Прогноз на завтра недоступен",
|
||||
"tomorrow_error": "Ошибка форматирования прогноза на завтра",
|
||||
"multiday_not_available": "Прогноз на {num_days} дней недоступен",
|
||||
"multiday_error": "Ошибка форматирования прогноза на {num_days} дней",
|
||||
"mqtt_forecast_not_supported": "Расширенный прогноз недоступен для MQTT-источников погоды",
|
||||
"mqtt_weather_no_subscriber": "MQTT-подписчик погоды не активен",
|
||||
"mqtt_weather_no_data": "Сообщение MQTT-погоды ещё не получено",
|
||||
"mqtt_weather_stale": "Данные MQTT-погоды устарели",
|
||||
"mqtt_weather_payload_error": "Ошибка полезной нагрузки MQTT: {detail}",
|
||||
"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": "⚠️ Экстремальный холод",
|
||||
"heavy_rain": "⚠️ Сильный дождь",
|
||||
"thunderstorms": "⚠️ Грозы",
|
||||
"heavy_snow": "⚠️ Сильный снегопад",
|
||||
"high_winds": "⚠️ Сильный ветер ({wind_speed} м/ч)"
|
||||
},
|
||||
"pressure_unit": "mmhg"
|
||||
},
|
||||
"sports": {
|
||||
"description": "Получить спортивные результаты (использование: sports [команда/лига])",
|
||||
"help": "Спортивные результаты. 'sports' для команд по умолчанию, 'sports [команда]' для конкретной команды.",
|
||||
"error_fetching": "Ошибка получения спортивных данных",
|
||||
"error_fetching_league": "Ошибка получения данных {sport}",
|
||||
"error_fetching_team": "Ошибка получения данных для {team}",
|
||||
"no_default_teams": "Команды по умолчанию не настроены",
|
||||
"no_games_default": "Игры для команд по умолчанию не найдены",
|
||||
"no_teams_city": "Команды для {city} не найдены",
|
||||
"no_games_city": "Игры для команд {city} не найдены",
|
||||
"no_games_league": "Игры для {sport} не найдены",
|
||||
"no_games_team": "Игры для {team} не найдены",
|
||||
"team_not_found": "Команда/Лига '{team}' не найдена. Попробуйте: seahawks, mariners, lfc, mlb, nfl и т.д."
|
||||
},
|
||||
"worldcup": {
|
||||
"description": "Счёт чемпионата мира FIFA, турнирная таблица",
|
||||
"help": "Счёт чемпионата мира. 'wc' - сегодняшние игры, 'wc group A' - таблица, 'wc [страна]' - матчи команды.",
|
||||
"not_in_season": "Чемпионат мира сейчас не проводится.",
|
||||
"no_games_today": "Сегодня нет игр чемпионата мира.",
|
||||
"no_games_nation": "Матчи для {nation} не найдены.",
|
||||
"groups_list": "Группы ЧМ: {groups}. Попробуйте 'wc group A'.",
|
||||
"group_not_found": "Группа '{group}' не найдена. Попробуйте букву группы, напр. 'wc group A'.",
|
||||
"nation_not_found": "Страна '{nation}' не найдена в текущем чемпионате мира.",
|
||||
"error_fetching": "Ошибка получения данных чемпионата мира"
|
||||
},
|
||||
"stats": {
|
||||
"description": "Статистика за 24 часа. 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'.",
|
||||
"help": "Статистика за 24ч. 'stats' (базовая), 'stats messages', 'stats channels', 'stats paths', 'stats adverts'",
|
||||
"subcommands": [
|
||||
{
|
||||
"name": "messages",
|
||||
"description": "Статистика сообщений пользователей"
|
||||
},
|
||||
{
|
||||
"name": "channels",
|
||||
"description": "Активность каналов"
|
||||
},
|
||||
{
|
||||
"name": "paths",
|
||||
"description": "Самые длинные маршруты"
|
||||
},
|
||||
{
|
||||
"name": "adverts",
|
||||
"description": "Топ узлов по advert-пакетам"
|
||||
}
|
||||
],
|
||||
"disabled": "Команда stats отключена",
|
||||
"unknown_subcommand": "Неизвестно: {subcommand}. Используйте 'stats', 'stats messages', 'stats channels', 'stats paths' или 'stats adverts'",
|
||||
"error": "Ошибка получения статистики: {error}",
|
||||
"error_bot_users": "Ошибка получения статистики пользователей: {error}",
|
||||
"error_channels": "Ошибка получения статистики каналов: {error}",
|
||||
"error_paths": "Ошибка получения статистики маршрутов: {error}",
|
||||
"basic": {
|
||||
"header": "Статистика бота (24ч):",
|
||||
"commands": "Cmds: {count} | Ответов: {replies}",
|
||||
"top_command": "Топ команда: {command}",
|
||||
"top_user": "Топ пользователь: {user}",
|
||||
"none": "Нет"
|
||||
},
|
||||
"users": {
|
||||
"header": "Пользователи бота (24ч):",
|
||||
"none": "Нет пользователей"
|
||||
},
|
||||
"channels": {
|
||||
"header": "Топ каналов (24ч)",
|
||||
"none": "Нет активности каналов",
|
||||
"msg_singular": "сообщ.",
|
||||
"msg_plural": "сообщ.",
|
||||
"user_singular": "пользователь",
|
||||
"user_plural": "пользователей",
|
||||
"format": "{rank}. {channel}: {msg_count} {msg_text} | {user_count} {user_text}"
|
||||
},
|
||||
"paths": {
|
||||
"none": "Нет данных маршрутов",
|
||||
"format": "{rank} {sender} {path}",
|
||||
"synopsis": "{rank} {sender}: {hops} хопов"
|
||||
},
|
||||
"adverts": {
|
||||
"header": "Топ advert-узлов (24ч):",
|
||||
"none": "Нет advert-активности",
|
||||
"advert_singular": "advert",
|
||||
"advert_plural": "adverts",
|
||||
"format": "{rank}. {name}: {count} {advert_text}",
|
||||
"hashes_label": "Хэши: {hashes}"
|
||||
},
|
||||
"error_adverts": "Ошибка получения advert-статистики: {error}"
|
||||
},
|
||||
"solarforecast": {
|
||||
"description": "Прогноз выработки солнечных панелей",
|
||||
"usage": "Использование: sf <локация|повторитель|координаты|индекс> [мощность] [азимут] [угол]",
|
||||
"usage_short": "Использование: sf <локация> [Вт] [азимут] [угол]",
|
||||
"panel_size_range": "Мощность панели должна быть 1-1000Вт",
|
||||
"azimuth_range": "Азимут должен быть от -180 до 180 (0=юг)",
|
||||
"angle_range": "Угол должен быть 0-90 градусов",
|
||||
"no_location": "Не удалось найти локацию: {location}",
|
||||
"error_fetching": "Ошибка получения прогноза",
|
||||
"no_data": "Нет данных прогноза",
|
||||
"error": "Ошибка: {error}",
|
||||
"rate_limit": "Лимит запросов: попробуйте через 5 мин",
|
||||
"labels": {
|
||||
"today": "Сегодня:{wh:.0f}Wh",
|
||||
"tomorrow": "Завтра:{wh:.0f}Wh",
|
||||
"day_format": "{day}:{wh:.0f}Wh",
|
||||
"peak": "Пик: {watts:.0f}Вт@{time}",
|
||||
"peak_past": "Пик: {watts:.0f}Вт (прошёл)",
|
||||
"hours_util": " ({hours}ч,{util:.0f}%)",
|
||||
"hours_percent": " ({hours}ч,{percent:.0f}%)",
|
||||
"hours_only": " ({hours}ч)",
|
||||
"separator": " | "
|
||||
}
|
||||
},
|
||||
"advert": {
|
||||
"description": "Отправить flood advert (только ЛС, кулдаун 1ч)",
|
||||
"cooldown_active": "Кулдаун advert. Подождите {minutes} мин.",
|
||||
"success": "Flood advert успешно отправлен!",
|
||||
"error": "Ошибка отправки flood advert: {error}"
|
||||
},
|
||||
"neighbors": {
|
||||
"description": "Запуск цикла обнаружения соседей (только ЛС)",
|
||||
"disabled": "Обнаружение соседей не включено. Установите neighbors_enabled = true в [PacketCapture].",
|
||||
"busy": "Цикл обнаружения уже запущен. Попробуйте позже.",
|
||||
"cooldown_active": "Цикл обнаружения недавно завершился. Подождите {minutes} мин.",
|
||||
"started": "Обнаружение соседей - слушаем ~{seconds}с. Сообщу по завершении.",
|
||||
"success": "Услышано {count} сосед(ей), лучший SNR {best_snr}. Записано {recorded} связь(ей).",
|
||||
"published": "Опубликовано в {succeeded}/{attempted} брокер(ов).",
|
||||
"none": "Обнаружение завершено, но ни один повторитель не ответил.",
|
||||
"failed": "Обнаружение не запущено: {reason}.",
|
||||
"error": "Ошибка обнаружения соседей: {error}"
|
||||
},
|
||||
"path": {
|
||||
"description": "Декодировать hex-данные маршрута",
|
||||
"help": "Path: path [hex] - Декодировать маршрут. path - текущий маршрут.",
|
||||
"no_valid_hex": "Не найдены валидные hex-значения.",
|
||||
"no_path": "Нет информации о маршруте",
|
||||
"error": "Ошибка обработки маршрута: {error}",
|
||||
"error_decoding": "Ошибка декодирования маршрута: {error}",
|
||||
"error_extracting": "Ошибка извлечения маршрута: {error}",
|
||||
"path_prefix": "Маршрут: {path_string}",
|
||||
"direct_connection": "Прямое соединение (0 хопов)",
|
||||
"node_format": "{node_id}: {name}",
|
||||
"node_unknown": "{node_id}: Неизвестно",
|
||||
"node_collision": "{node_id}: {matches} повторителей",
|
||||
"node_geographic": "{node_id}: {name} {confidence}",
|
||||
"continuation_start": "...\n{line}",
|
||||
"continuation_end": "\n...",
|
||||
"truncation": "...",
|
||||
"unknown_name": "Неизвестно",
|
||||
"repeater_resolution_deferred": "Маршрут: {path}\n\nПодсказка: Идентификация повторителей требует {minimum_path_bytes}-битные маршруты."
|
||||
},
|
||||
"multitest": {
|
||||
"description": "Слушает 6 сек и собирает уникальные пути от входящих сообщений с тем же хэшем",
|
||||
"help": "Слушает 6 сек и собирает уникальные пути от входящих сообщений с тем же хэшем"
|
||||
},
|
||||
"trace": {
|
||||
"description": "Трассировка ссылки (trace=ручной путь, tracer=круговой)",
|
||||
"help": "trace [path] - ручной путь. tracer [path] - круговой. Путь: запятые 2-хекс. (напр. 01,7a,55)."
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Поиск повторителей по двухсимвольному префиксу",
|
||||
"help_api": "Поиск повторителей по префиксу. Использование: 'prefix 1A'.",
|
||||
"help_no_api": "Поиск повторителей по префиксу (только локальная БД).",
|
||||
"location_note": " (с названиями городов)",
|
||||
"refresh_not_available": "Обновление недоступно - нет API. Только локальная БД.",
|
||||
"cache_refreshed": "Кэш префиксов обновлён!",
|
||||
"unable_determine_free": "Не удалось определить свободные префиксы. Попробуйте 'prefix refresh'.",
|
||||
"invalid_format": "Неверный формат префикса. Ожидается {prefix_hex_chars} hex-символа.",
|
||||
"no_repeaters_found": "Повторители с префиксом '{prefix}' не найдены",
|
||||
"no_free_prefixes": "Свободные префиксы не найдены",
|
||||
"available_prefixes": "Доступные префиксы ({shown} из {total} свободных):",
|
||||
"generate_key": "Сгенерировать ключ: https://gessaman.com/mc-keygen",
|
||||
"prefix_db_all": "Префикс {prefix}: {count} повторитель(ей)",
|
||||
"prefix_db_recent": "Префикс {prefix}: {count} повторитель(ей) ({days})",
|
||||
"older_entries_note": "(Записи могут быть старше {days} дней)",
|
||||
"prefix_api": "Префикс {prefix} ({count} повторитель(ей)):",
|
||||
"item_format": "{index}. {name}",
|
||||
"source_domain": "Источник: {domain}",
|
||||
"source_api": "Источник: API",
|
||||
"device_repeater": " (Повторитель)",
|
||||
"device_roomserver": " (Сервер комнат)"
|
||||
},
|
||||
"repeater": {
|
||||
"description": "Управление контактами повторителей (только ЛС)",
|
||||
"admin_only": "Команда repeater требует прав администратора"
|
||||
},
|
||||
"airplanes": {
|
||||
"description": "Самолёты над головой",
|
||||
"usage": "Использование: airplanes [локация] [опции]",
|
||||
"subcommands": [
|
||||
{
|
||||
"name": "overhead",
|
||||
"description": "Ближайший самолёт над головой"
|
||||
}
|
||||
],
|
||||
"no_location": "Нет локации. Укажите координаты: airplanes 47.6,-122.3",
|
||||
"overhead_no_location": "Локация неизвестна. Отправьте 'advert' или укажите координаты.",
|
||||
"no_aircraft": "Самолёты в радиусе {radius}мм не найдены",
|
||||
"no_aircraft_filtered": "Самолёты по фильтрам в радиусе {radius}мм не найдены",
|
||||
"api_error": "Не удалось получить данные. API может быть недоступен.",
|
||||
"invalid_coords": "Неверные координаты. Формат: широта,долгота (напр. 47.6,-122.3)",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"aqi": {
|
||||
"description": "Индекс качества воздуха для локации",
|
||||
"usage": "Использование: aqi <город|район|координаты>",
|
||||
"usage_help": "Использование: aqi <город|координаты|help>",
|
||||
"error_fetching": "Ошибка получения данных IQВ",
|
||||
"error_fetching_openmeteo": "Ошибка получения данных IQВ из OpenMeteo",
|
||||
"no_data": "Данные IQВ недоступны",
|
||||
"no_location": "Не удалось найти локацию: {location}",
|
||||
"no_location_city": "Не удалось найти город '{location}' в {state}",
|
||||
"no_location_city_international": "Не удалось найти город '{location}'",
|
||||
"no_location_zipcode": "Не удалось найти индекс '{zipcode}'",
|
||||
"error": "Ошибка получения данных IQВ: {error}",
|
||||
"error_geocoding_zipcode": "Ошибка геокодирования индекса: {error}",
|
||||
"error_formatting": "Ошибка форматирования данных IQВ",
|
||||
"invalid_latitude": "Неверная широта: {lat}. Должна быть от -90 до 90.",
|
||||
"invalid_longitude": "Неверная долгота: {lon}. Должна быть от -180 до 180.",
|
||||
"invalid_coordinates_format": "Неверный формат координат: {location}. Формат: широта,долгота",
|
||||
"pollutant_help": "IQВ: PM2.5=мелкие частицы, PM10=крупные, O3=озон, NO2=азот, CO=угарный, SO2=серный",
|
||||
"categories": {
|
||||
"good": "Хорошо",
|
||||
"moderate": "Умеренно",
|
||||
"unhealthy_sensitive": "Вредно для чувствительных",
|
||||
"unhealthy": "Вредно",
|
||||
"very_unhealthy": "Очень вредно",
|
||||
"hazardous": "Опасно",
|
||||
"fair": "Нормально",
|
||||
"poor": "Плохо",
|
||||
"very_poor": "Очень плохо",
|
||||
"na": "Н/Д"
|
||||
}
|
||||
},
|
||||
"rain": {
|
||||
"description": "Радар осадков: когда начнётся/прекратится дождь в ближайшие ~2ч",
|
||||
"help_rain": "Радар дождя: когда начнётся/прекратится ~2ч. rain [город|индекс|координаты]. Также: snow",
|
||||
"help_snow": "Радар снега: когда начнётся/прекратится ~2ч. snow [город|индекс|координаты]. Также: rain",
|
||||
"usage": "Использование: rain|snow [город|индекс|координаты]",
|
||||
"usage_short": "Использование: rain|snow [город|индекс|координаты]",
|
||||
"starting": "{emoji} {ptype} начнётся через ~{minutes}мин{extra} в {location}",
|
||||
"stopping": "{emoji} {ptype} прекратится через ~{minutes}мин в {location}",
|
||||
"continuing": "{emoji} {ptype} продолжается {window}+ в {location}",
|
||||
"clear": "☀️ Без {precip} в ближайшие {window} в {location}",
|
||||
"changeover": "{from_emoji}→{to_emoji} {first} {when} → {second} через ~{minutes}мин в {location}",
|
||||
"now": "сейчас",
|
||||
"duration_for": " (~{duration}мин)",
|
||||
"duration_open": " (непрерывно)",
|
||||
"error_fetching": "Ошибка получения данных радара",
|
||||
"no_location": "Использование: rain [город|индекс|координаты].",
|
||||
"no_location_city": "Не удалось найти город '{location}' в {state}",
|
||||
"no_location_zipcode": "Не удалось найти индекс '{location}'",
|
||||
"error": "Ошибка: {error}",
|
||||
"precip_types": {
|
||||
"drizzle": "Морось",
|
||||
"rain": "Дождь",
|
||||
"heavy_rain": "Сильный дождь",
|
||||
"freezing": "Ледяной дождь",
|
||||
"snow": "Снег",
|
||||
"showers": "Ливень",
|
||||
"thunder": "Гроза"
|
||||
}
|
||||
},
|
||||
"aurora": {
|
||||
"description": "Прогноз полярного сияния (индекс KP)",
|
||||
"usage": "Использование: aurora [город|индекс|координаты]",
|
||||
"usage_short": "Использование: aurora [город|индекс|координаты]",
|
||||
"response": "Вероятность полярного сияния: {prob_indicator} {prob}% для {location}\nKp {kp} (на {kp_time}) | {status}",
|
||||
"error_fetching": "Ошибка получения данных авроры",
|
||||
"no_location": "Использование: aurora [город|индекс|координаты].",
|
||||
"no_location_city": "Не удалось найти город '{location}' в {state}",
|
||||
"no_location_zipcode": "Не удалось найти индекс '{location}'",
|
||||
"error": "Ошибка: {error}",
|
||||
"status": {
|
||||
"quiet": "Спокойно",
|
||||
"unsettled": "Нестабильно",
|
||||
"g1_g2": "G1/G2",
|
||||
"g3_severe": "G3+ Сильная буря"
|
||||
}
|
||||
},
|
||||
"solar": {
|
||||
"description": "Солнечная активность и 상태 HF-диапазонов",
|
||||
"response": "Солнце: {info}",
|
||||
"error": "Ошибка получения солнечной информации: {error}",
|
||||
"labels": {
|
||||
"a_index": "A-индекс",
|
||||
"k_index": "K-индекс",
|
||||
"sunspots": "Солнечные пятна",
|
||||
"xray_flux": "Рентгеновский поток",
|
||||
"solar_flux": "Солнечный поток",
|
||||
"signal_noise": "Шум сигнала",
|
||||
"error_fetching": "Ошибка получения данных",
|
||||
"no_xray_data": "Нет данных по рентгену",
|
||||
"not_configured": "не настроено, обратитесь к администратору"
|
||||
},
|
||||
"condensed_format": "A:{a_index} K:{k_index} Сп:{sunspots} Пот:{solar_flux} Рент:{xray_flux} Шум:{signal_noise}"
|
||||
},
|
||||
"sun": {
|
||||
"description": "Время восхода/заката",
|
||||
"help": "Время восхода/заката и позиция солнца",
|
||||
"response": "Солнце:\n{info}",
|
||||
"error": "Ошибка получения информации о солнце: {error}",
|
||||
"labels": {
|
||||
"sunset": "Закат",
|
||||
"sunrise": "Восход",
|
||||
"rise": "Восход",
|
||||
"set": "Закат",
|
||||
"daylight": "Дневной свет",
|
||||
"remaining": "Осталось",
|
||||
"azimuth": "Азимут",
|
||||
"altitude": "Высота"
|
||||
}
|
||||
},
|
||||
"moon": {
|
||||
"description": "Фаза луны и время восхода/заката",
|
||||
"error": "Ошибка получения информации о луне: {error}",
|
||||
"format": "{phase} {illum}\nВосход:{rise} Заход:{set}",
|
||||
"format_with_dates": "{phase} {illum}\nВосход:{rise} Заход:{set}\nПолнолуние:{full} Новолуние:{new}",
|
||||
"fallback": "{info}",
|
||||
"unknown_phase": "Неизвестно",
|
||||
"unknown_illum": "Н/Д",
|
||||
"labels": {
|
||||
"moonrise": "Восход луны",
|
||||
"set": "Заход",
|
||||
"phase": "Фаза",
|
||||
"fullmoon": "Полнолуние",
|
||||
"newmoon": "Новолуние",
|
||||
"az": "Азимут",
|
||||
"alt": "Высота"
|
||||
},
|
||||
"phases": {
|
||||
"new_moon": "Новолуние🌑",
|
||||
"waxing_crescent": "Растущий серп🌒",
|
||||
"first_quarter": "Первая четверть🌓",
|
||||
"waxing_gibbous": "Растущая луна🌔",
|
||||
"full_moon": "Полнолуние🌕",
|
||||
"waning_gibbous": "Убывающая луна🌖",
|
||||
"last_quarter": "Последняя четверть🌗",
|
||||
"waning_crescent": "Убывающий серп🌘"
|
||||
}
|
||||
},
|
||||
"hfcond": {
|
||||
"description": "Условия распространения HF",
|
||||
"help": "HF-условия для любительской радиосвязи",
|
||||
"header": "HF-условия:\n{info}",
|
||||
"error": "Ошибка получения HF-условий: {error}"
|
||||
},
|
||||
"satpass": {
|
||||
"description": "Информация о пролёте спутника",
|
||||
"help": "Прогноз пролётов спутников",
|
||||
"header": "Пролёт спутника:\n{pass_info}",
|
||||
"error": "Ошибка получения информации о пролёте: {error}",
|
||||
"no_satellite": "Укажите NORAD-номер или сокращение. Пример: satpass iss",
|
||||
"usage_short": "satpass <спутник|NORAD> [visual]",
|
||||
"help_header": "Информация о пролётах спутников\n\nИспользование: satpass <NORAD_номер>",
|
||||
"category_weather": "Погода: ",
|
||||
"category_stations": "Станции: ",
|
||||
"category_telescopes": "Телескопы: ",
|
||||
"category_other": "Другое: ",
|
||||
"examples": "\nПримеры:\n• satpass iss\n• satpass noaa15\n• satpass 25544",
|
||||
"labels": {
|
||||
"geostationary": "{satname}: ГЕО (нет LEO пролёта)",
|
||||
"no_passes": "{satname}: нет пролётов в ближайшие 10 дней",
|
||||
"invalid_norad": "Укажите NORAD#. Пример: satpass 25544"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"description": "Список каналов",
|
||||
"help": "Список каналов. 'channels' - основные, 'channels list' - по категориям.",
|
||||
"subcommands": [
|
||||
{
|
||||
"name": "list",
|
||||
"description": "Список каналов в категории"
|
||||
}
|
||||
],
|
||||
"no_channels_for_category": "Каналы для '{category}' не настроены. Используйте 'channels'.",
|
||||
"no_channels_configured": "Каналы не настроены. Обратитесь к администратору.",
|
||||
"error_retrieving_channels": "Ошибка получения каналов: {error}",
|
||||
"no_categories_configured": "Категории каналов не настроены.",
|
||||
"error_retrieving_categories": "Ошибка получения категорий: {error}",
|
||||
"no_description_available": "Описание отсутствует",
|
||||
"channel_not_found": "Канал {channel} не найден. Используйте 'channels list'.",
|
||||
"error_retrieving_channel_info": "Ошибка получения информации о канале: {error}",
|
||||
"no_category_channels": "Каналы {category} не настроены",
|
||||
"headers": {
|
||||
"common_channels": "Основные каналы: ",
|
||||
"common_channels_cont": "Основные каналы: ",
|
||||
"available_categories": "Доступные категории: ",
|
||||
"channels_cont": "Каналы (продолжение): ",
|
||||
"category": "{category}: ",
|
||||
"category_channels": "Каналы {category}: "
|
||||
},
|
||||
"category_count": "{category} ({count} каналов)"
|
||||
},
|
||||
"dice": {
|
||||
"description": "Бросок кубика",
|
||||
"help": "Бросок кубика. 'dice' - d6, 'dice d20' - d20, 'dice 2d6'.",
|
||||
"invalid_dice_type": "Неверный тип кубика.",
|
||||
"single_die": "d{sides}: {result}",
|
||||
"multiple_dice": "{count}d{sides}: [{results}] = {total}"
|
||||
},
|
||||
"roll": {
|
||||
"description": "Случайное число от 1 до X (по умолчанию 100)",
|
||||
"help": "Случайное число от 1 до X. 'roll' - 1-100, 'roll 50' - 1-50.",
|
||||
"result": "Бросок 1-{max}: {result}",
|
||||
"invalid_number": "Неверное число."
|
||||
},
|
||||
"roll_old": {
|
||||
"description": "Бросок кубика"
|
||||
},
|
||||
"joke": {
|
||||
"description": "Случайная шутка"
|
||||
},
|
||||
"dadjoke": {
|
||||
"description": "Случайная папаша-шутка"
|
||||
},
|
||||
"catfact": {
|
||||
"description": "Случайный факт о котах",
|
||||
"error": "Мяу? Что-то пошло не так!"
|
||||
},
|
||||
"hacker": {
|
||||
"description": "Симуляция взлома с юмористическими ошибками"
|
||||
},
|
||||
"hello": {
|
||||
"description": "Отвечает на приветствия",
|
||||
"help": "Отвечает на приветствия роботизированными ответами",
|
||||
"response_format": "Я {bot_name}."
|
||||
},
|
||||
"webviewer": {
|
||||
"description": "Управление веб-просмотрщиком (только админ)"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "Список доступных команд"
|
||||
}
|
||||
},
|
||||
"elapsed": {
|
||||
"sync_device_clock": "Синхронизация часов устройства"
|
||||
},
|
||||
"errors": {
|
||||
"rate_limited": "Ограничение частоты. Подождите {seconds:.1f} сек",
|
||||
"cooldown": "Команда '{command}' на кулдауне. Подождите {seconds} сек.",
|
||||
"access_denied": "Доступ запрещён: команда '{command}' требует прав администратора",
|
||||
"dm_only": "Команда '{command}' доступна только в ЛС",
|
||||
"execution_error": "Ошибка выполнения {command}: {error}",
|
||||
"unknown_command": "Неизвестная команда: {command}",
|
||||
"no_response": "Нет ответа",
|
||||
"generic_error": "Ошибка: {error}"
|
||||
},
|
||||
"messages": {
|
||||
"processing": "Обработка сообщения: {content}",
|
||||
"command_executed": "Команда выполнена",
|
||||
"keyword_matched": "Ключевое слово '{keyword}' совпало, отвечаю"
|
||||
},
|
||||
"common": {
|
||||
"unknown": "Неизвестно",
|
||||
"unknown_routing": "Неизвестная маршрутизация",
|
||||
"unknown_sender": "Неизвестно",
|
||||
"unknown_path": "Неизвестно",
|
||||
"unknown_timestamp": "Неизвестно",
|
||||
"no_help_available": "Справка для этой команды недоступна.",
|
||||
"snr_label": "SNR: {value} дБ",
|
||||
"rssi_label": "RSSI: {value} дБм",
|
||||
"temp_high_label": "В",
|
||||
"temp_low_label": "Н",
|
||||
"date_time": {
|
||||
"day_abbreviations": {
|
||||
"Mon": "Пн",
|
||||
"Tue": "Вт",
|
||||
"Wed": "Ср",
|
||||
"Thu": "Чт",
|
||||
"Fri": "Пт",
|
||||
"Sat": "Сб",
|
||||
"Sun": "Вс"
|
||||
},
|
||||
"days": {
|
||||
"Monday": "Понедельник",
|
||||
"Tuesday": "Вторник",
|
||||
"Wednesday": "Среда",
|
||||
"Thursday": "Четверг",
|
||||
"Friday": "Пятница",
|
||||
"Saturday": "Суббота",
|
||||
"Sunday": "Воскресенье"
|
||||
},
|
||||
"month_abbreviations": {
|
||||
"Jan": "янв",
|
||||
"Feb": "фев",
|
||||
"Mar": "мар",
|
||||
"Apr": "апр",
|
||||
"May": "май",
|
||||
"Jun": "июн",
|
||||
"Jul": "июл",
|
||||
"Aug": "авг",
|
||||
"Sep": "сен",
|
||||
"Oct": "окт",
|
||||
"Nov": "ноя",
|
||||
"Dec": "дек"
|
||||
},
|
||||
"months": {
|
||||
"January": "Январь",
|
||||
"February": "Февраль",
|
||||
"March": "Март",
|
||||
"April": "Апрель",
|
||||
"May": "Май",
|
||||
"June": "Июнь",
|
||||
"July": "Июль",
|
||||
"August": "Август",
|
||||
"September": "Сентябрь",
|
||||
"October": "Октябрь",
|
||||
"November": "Ноябрь",
|
||||
"December": "Декабрь"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"basic": "Основные команды",
|
||||
"custom_syntax": "Пользовательский синтаксис",
|
||||
"special": "Специальные команды",
|
||||
"weather": "Погода",
|
||||
"solar": "Солнце",
|
||||
"sports": "Спорт",
|
||||
"analytics": "Аналитика",
|
||||
"meshcore_info": "Информация MeshCore",
|
||||
"management": "Управление"
|
||||
},
|
||||
"services": {
|
||||
"darcmowas": {
|
||||
"messagetype": {
|
||||
"eu-alert-level-1": "Экстренное оповещение",
|
||||
"eu-alert-level-2": "Экстремальная опасность",
|
||||
"eu-alert-level-3": "Значительная опасность",
|
||||
"eu-alert-level-4": "Информация об опасности",
|
||||
"eu-test": "Тестовое оповещение"
|
||||
},
|
||||
"fields": {
|
||||
"sender": "Отправитель: {sender}",
|
||||
"area": "Район: {areas}"
|
||||
}
|
||||
},
|
||||
"weather_service": {
|
||||
"daily_weather": "Погода",
|
||||
"tomorrow": "Завтра",
|
||||
"error_fetching": "Ошибка получения данных погоды",
|
||||
"no_data": "Данные прогноза недоступны",
|
||||
"near": "в районе",
|
||||
"est": "ориент.",
|
||||
"steady": "непрерывно",
|
||||
"rain_starting": "{emoji} {ptype} начнётся через ~{minutes}мин{est}{steady}{temp}{location}",
|
||||
"rain_ending": "{emoji} {ptype} прекратится через ~{minutes}мин{est}{temp}{location}",
|
||||
"weather_descriptions": {
|
||||
"0": "Ясно",
|
||||
"1": "Преим.ясно",
|
||||
"2": "Перем.облач.",
|
||||
"3": "Пасмурно",
|
||||
"45": "Туман",
|
||||
"48": "Изморось",
|
||||
"51": "Лёгк.морось",
|
||||
"53": "Морось",
|
||||
"55": "Сильн.морось",
|
||||
"56": "Лёгк.лед.морось",
|
||||
"57": "Лед.морось",
|
||||
"61": "Небол.дождь",
|
||||
"63": "Дождь",
|
||||
"65": "Сильн.дождь",
|
||||
"66": "Слаб.лед.дождь",
|
||||
"67": "Лед.дождь",
|
||||
"71": "Небол.снег",
|
||||
"73": "Снег",
|
||||
"75": "Сильн.снег",
|
||||
"77": "Снеж.зерна",
|
||||
"80": "Небол.ливень",
|
||||
"81": "Ливень",
|
||||
"82": "Сильн.ливень",
|
||||
"85": "Слаб.снеж.ливень",
|
||||
"86": "Снеж.ливень",
|
||||
"95": "Гроза",
|
||||
"96": "Гроза+град",
|
||||
"99": "Сильн.гроза"
|
||||
},
|
||||
"wind_speed_units": {
|
||||
"ms": "м/с",
|
||||
"mph": "mph",
|
||||
"kmh": "км/ч",
|
||||
"kn": "уз"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user