Merge pull request #261 from custardpy/feat/weather-service-localization

feat(i18n): localize weather outputs and add Russian translations
This commit is contained in:
Adam Gessaman authored and GitHub committed 2026-09-07 15:19:03 -07:00
commit c9ff277fd2
17 files changed
+2557 -669

No files matched your search

+110
View File
@@ -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
View File
@@ -806,11 +806,18 @@ wind_speed_unit = mph
# Default: inch
precipitation_unit = inch
# Note: gwx shows visibility in km when temperature_unit = celsius, miles
# otherwise. The pressure unit follows the language instead (mmHg for ru,
# hPa elsewhere) since that is a locale convention, not a metric/imperial split.
# How to show daily high/low temperatures (wx, gwx, Weather_Service).
# Placeholders: {high} {low} {units} ({units} is °F or °C)
temperature_high_low_format = H:{high}{units} L:{low}{units}
temperature_high_only_format = H:{high}{units}
temperature_low_only_format = L:{low}{units}
# {high_label} {low_label} (localized H/L, per [Localization] language)
# Keep {high_label}/{low_label} to follow the configured language; hardcode
# H:/L: only if you want English labels regardless of language.
temperature_high_low_format = {high_label}:{high}{units} {low_label}:{low}{units}
temperature_high_only_format = {high_label}:{high}{units}
temperature_low_only_format = {low_label}:{low}{units}
# Examples:
# temperature_high_low_format = ↓{low}°↑{high}{units}
# temperature_high_low_format = H:{high}{units} L:{low}{units}
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""
Shared formatting for NWS weather alerts
Used by both !wx alerts (WxCommand) and the proactive WeatherService broadcasts
"""
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional
# Month keys are the English abbreviations used throughout the catalogs; the
# translated value comes from common.date_time.month_abbreviations.
MONTH_KEYS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
FULL_MONTH_KEYS = ("January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December")
SEVERITY_EMOJI = {
'Extreme': '🔴',
'Severe': '🟠',
'Moderate': '🟡',
'Minor': '⚪',
'Unknown': '⚪',
}
# English fallbacks for the four event types NWS actually publishes. Anything
# else (NWS also emits titles we classify as "Unknown") falls back to the raw
# event_type rather than a translation key.
EVENT_TYPE_ABBREV = {
'Warning': 'Warn',
'Watch': 'Watch',
'Advisory': 'Adv',
'Statement': 'Stmt',
}
CITY_ABBREV = {
"Seattle": "SEA", "Portland": "PDX", "San Francisco": "SF",
"Los Angeles": "LA", "New York": "NYC", "Chicago": "CHI",
"Houston": "HOU", "Phoenix": "PHX", "Philadelphia": "PHL",
"San Antonio": "SAT", "San Diego": "SAN", "Dallas": "DAL",
"San Jose": "SJC", "Austin": "AUS", "Jacksonville": "JAX",
"Columbus": "CMH", "Fort Worth": "FTW", "Charlotte": "CLT",
"Denver": "DEN", "Washington": "DC", "Boston": "BOS",
"El Paso": "ELP", "Detroit": "DTW", "Nashville": "BNA",
"Oklahoma City": "OKC", "Las Vegas": "LAS", "Memphis": "MEM",
"Louisville": "SDF", "Baltimore": "BWI", "Milwaukee": "MKE",
"Albuquerque": "ABQ", "Tucson": "TUS", "Fresno": "FAT",
"Sacramento": "SAC", "Kansas City": "KC", "Mesa": "MSC",
"Atlanta": "ATL", "Omaha": "OMA", "Colorado Springs": "COS",
"Raleigh": "RDU", "Virginia Beach": "ORF", "Miami": "MIA",
"Oakland": "OAK", "Minneapolis": "MSP", "Tulsa": "TUL",
"Cleveland": "CLE", "Wichita": "ICT", "Arlington": "ARL",
"Tampa": "TPA", "New Orleans": "MSY", "Honolulu": "HNL",
"Anchorage": "ANC", "Bellingham": "BLI", "Everett": "EVE",
"Spokane": "GEG", "Tacoma": "TAC", "Yakima": "YKM",
"Olympia": "OLM", "Vancouver": "YVR", "Victoria": "YYJ",
}
def translate_or(translator: Any, key: str, default: str, **kwargs: Any) -> str:
"""Translate ``key``, falling back to ``default`` when it is not in any catalog.
``Translator.translate`` deliberately echoes the key back when a lookup
misses in both the requested locale and the English fallback, which makes
missing translations visible in development but leaks a dotted key path
into a mesh broadcast in production. Callers with a sensible English
default should use this instead.
Args:
translator: Object with a ``translate(key, **kwargs)`` method, or None.
key: Dot-separated key path.
default: Value to use when the key is absent.
**kwargs: Formatting parameters for the translated string.
Returns:
str: Translated string, or ``default`` formatted with ``kwargs``.
"""
if translator is not None:
value = translator.translate(key, **kwargs)
if value != key:
return value
if kwargs:
try:
return default.format(**kwargs)
except (KeyError, ValueError, IndexError):
return default
return default
def severity_emoji(severity: str) -> str:
"""Return the colored dot for an alert severity.
Args:
severity: NWS severity ('Extreme', 'Severe', 'Moderate', 'Minor', ...).
Returns:
str: Emoji for the severity, defaulting to the 'Unknown' dot.
"""
return SEVERITY_EMOJI.get(severity, '⚪')
def event_type_abbrev(event_type: str, translator: Any = None) -> str:
"""Abbreviate an alert event type ('Warning' -> 'Warn').
Args:
event_type: NWS event type, or any string our title parser produced.
translator: Optional translator for localized abbreviations.
Returns:
str: Localized abbreviation, or ``event_type`` unchanged when we have
no abbreviation for it.
"""
if not event_type:
return ""
return translate_or(
translator,
f'common.alerts.event_types.{event_type}',
EVENT_TYPE_ABBREV.get(event_type, event_type),
)
def abbreviate_city_name(city: str) -> str:
"""Abbreviate a city name for compact display (Seattle -> SEA).
Args:
city: City name from an NWS office string.
Returns:
str: Known abbreviation, else the initials of the first three words,
else the first four characters upper-cased.
"""
if not city:
return city
if city in CITY_ABBREV:
return CITY_ABBREV[city]
# Partial match handles "Seattle WA" -> "SEA"
for full_name, abbrev in CITY_ABBREV.items():
if full_name in city:
return abbrev
words = city.split()
if len(words) > 1:
initials = ''.join(word[0].upper() for word in words[:3])
if len(initials) <= 4:
return initials
return city[:4].upper() if len(city) >= 4 else city.upper()
@dataclass(frozen=True)
class AlertTime:
"""A parsed alert timestamp with its locale-specific renderings.
Holding the parts rather than a formatted string is what lets callers pick
between the time-only and dated forms without re-parsing localized output
with English-shaped regexes.
"""
month: str
day: int
hour_12: int
meridiem: str
time_only: str
dated: str
def parse_display_time(time_str: str, translator: Any = None) -> Optional[AlertTime]:
"""Parse an ISO-8601 alert timestamp into its localized parts.
Args:
time_str: Timestamp from an NWS alert (e.g. '2025-12-17T01:00:00-08:00').
translator: Optional translator for month names and AM/PM.
Returns:
Optional[AlertTime]: Parsed parts, or None when ``time_str`` is not ISO
format or cannot be parsed.
"""
if not time_str or 'T' not in time_str or not re.match(r'\d{4}-\d{2}-\d{2}T', time_str):
return None
try:
dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except ValueError:
# NWS occasionally emits a truncated timestamp ('2025-12-17T01:0').
try:
date_part, _, clock_part = time_str.partition('T')
clock_part = re.split(r'[-+]', clock_part)[0]
dt = datetime.fromisoformat(f"{date_part}T{clock_part}")
except (ValueError, IndexError):
return None
month_key = MONTH_KEYS[dt.month - 1]
month = translate_or(translator, f'common.date_time.month_abbreviations.{month_key}', month_key)
hour = dt.hour
if hour == 0:
hour_12, meridiem_key, meridiem_default = 12, 'am', 'AM'
elif hour < 12:
hour_12, meridiem_key, meridiem_default = hour, 'am', 'AM'
elif hour == 12:
hour_12, meridiem_key, meridiem_default = 12, 'pm', 'PM'
else:
hour_12, meridiem_key, meridiem_default = hour - 12, 'pm', 'PM'
meridiem = translate_or(translator, f'common.alerts.{meridiem_key}', meridiem_default)
# Russian writes "6 дня" where English writes "6PM", so the separator is
# the locale's business, not ours.
time_only = translate_or(
translator, 'common.alerts.time_12h', '{hour}{meridiem}',
hour=hour_12, meridiem=meridiem,
)
dated = translate_or(
translator, 'common.alerts.date_12h', '{month} {day} {time}',
month=month, day=dt.day, time=time_only,
)
return AlertTime(month=month, day=dt.day, hour_12=hour_12, meridiem=meridiem,
time_only=time_only, dated=dated)
def compact_time(time_str: str, translator: Any = None) -> str:
"""Shorten an alert timestamp for a mesh message.
ISO timestamps become the localized dated form ('Dec 17 1AM'). Free-text
NWS strings ('December 16 at 3:12PM') get their month abbreviated and their
':00' minutes and filler 'at' dropped.
Args:
time_str: Timestamp or free-text time from an NWS alert.
translator: Optional translator for month names and AM/PM.
Returns:
str: Compacted time string, or ``time_str`` unchanged when empty.
"""
if not time_str:
return time_str
parsed = parse_display_time(time_str, translator)
if parsed is not None:
return parsed.dated
# Remove leading zeros from hours: "6:00AM" -> "6AM"
time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str)
# Abbreviate month names. Longest first so "June" is not matched by "Jun".
for full_key, abbrev_key in zip(FULL_MONTH_KEYS, MONTH_KEYS, strict=True):
if full_key not in time_str:
continue
abbrev = translate_or(translator, f'common.date_time.month_abbreviations.{abbrev_key}', abbrev_key)
time_str = time_str.replace(full_key, abbrev)
# Remove "at" before time: "December 16 at 3:12PM" -> "Dec 16 3:12PM"
time_str = re.sub(r'\s+at\s+', ' ', time_str)
# NWS writes these titles in English ("until December 17 at 6:00AM PST"),
# so localize the meridiem the same way the ISO path does.
def _localize_meridiem(match: "re.Match[str]") -> str:
meridiem = translate_or(
translator, f'common.alerts.{match.group(2).lower()}', match.group(2).upper()
)
return translate_or(
translator, 'common.alerts.time_12h', '{hour}{meridiem}',
hour=match.group(1), meridiem=meridiem,
)
time_str = re.sub(r'(\d+(?::\d+)?)\s*(AM|PM)\b', _localize_meridiem, time_str,
flags=re.IGNORECASE)
return time_str
def shorten_event(event: str, limit: Optional[int] = 15, max_words: int = 2) -> str:
"""Trim a long event name to its leading words.
Args:
event: NWS event name (e.g. 'High Wind Warning').
limit: Length above which the name is trimmed, or None to never trim.
max_words: Number of leading words to keep when trimming.
Returns:
str: Shortened event name.
"""
if limit is None or len(event) <= limit:
return event
words = event.split()
if len(words) > max_words:
return ' '.join(words[:max_words])
return event[:limit]
def format_event_label(event: str, event_type: str, translator: Any = None,
limit: Optional[int] = 15, max_words: int = 2) -> str:
"""Render '<event> <type-abbrev>', dropping the type when it is redundant.
Args:
event: NWS event name (e.g. 'High Wind Warning').
event_type: NWS event type (e.g. 'Warning').
translator: Optional translator for the type abbreviation.
limit: Length above which the event name is trimmed, or None to never trim.
max_words: Number of leading words to keep when trimming.
Returns:
str: Combined label, or just the abbreviation when ``event`` is empty.
"""
abbrev = event_type_abbrev(event_type, translator)
if not event:
return abbrev
short = shorten_event(event, limit, max_words)
if event_type and event_type.lower() in event.lower():
# "High Wind Warning" already says "Warning"
return short
return f"{short} {abbrev}" if abbrev else short
def format_event_plain(event: str, event_type: str, translator: Any = None) -> str:
"""Render '<event> <type-abbrev>' with no trimming and no redundancy check.
This is the fallback form used when a message has already overrun its
budget and the caller is retrying with less detail.
Args:
event: NWS event name.
event_type: NWS event type.
translator: Optional translator for the type abbreviation.
Returns:
str: Combined label, or just the abbreviation when ``event`` is empty.
"""
abbrev = event_type_abbrev(event_type, translator)
return f"{event} {abbrev}" if event else abbrev
def first_location(area_desc: str, limit: int = 20) -> str:
"""Extract one short place name from an NWS area description.
Args:
area_desc: Semicolon-separated areas ('King County; Snohomish County').
limit: Maximum length of the returned name.
Returns:
str: Short place name, or '' when ``area_desc`` is empty.
"""
if not area_desc:
return ""
first = area_desc.split(';')[0].strip()
if ',' in first:
# "Seattle, WA" -> "Seattle"
location = first.split(',')[0].strip()
else:
words = first.split()
if len(words) > 1 and words[-1].lower() in ('county', 'parish', 'borough'):
location = words[0]
else:
location = first
return location[:limit]
def format_office(office: str, translator: Any = None, limit: int = 10) -> str:
"""Render the issuing office compactly ('NWS Seattle WA' -> 'by NWS SEA').
Args:
office: Office string from the alert.
translator: Optional translator for the 'by' label.
limit: Length to truncate a single-token office to.
Returns:
str: Formatted office attribution, or '' when ``office`` is empty.
"""
if not office:
return ""
by_label = translate_or(translator, 'common.alerts.by', 'by')
parts = office.split()
if len(parts) >= 2:
return f"{by_label} {parts[0]} {abbreviate_city_name(parts[1])}"
return f"{by_label} {office[:limit]}"
def format_alert_compact(alert: dict[str, Any], translator: Any = None,
include_details: bool = True,
include_location: bool = True) -> str:
"""Format one alert for a mesh message.
Produces "🟠High Wind Warn King til 6AM by NWS SEA" (details) or
"🟠High Wind Warn" (summary). The caller is responsible for appending a
shortened link, which needs async work.
Args:
alert: Alert dict with event, event_type, severity, expires, office,
and optionally area_desc.
translator: Optional translator for all labels.
include_details: If True, include location, expiry and office.
include_location: If True (and ``include_details``), include the area.
Returns:
str: Formatted alert string.
"""
event = alert.get('event', '')
event_type = alert.get('event_type', '')
severity = alert.get('severity', 'Unknown')
emoji = severity_emoji(severity)
if not include_details:
return emoji + format_event_plain(event, event_type, translator)
result = emoji + format_event_label(event, event_type, translator)
if include_location:
location = first_location(alert.get('area_desc', ''))
if location:
result += f" {location}"
expires = alert.get('expires', '')
if expires:
til_label = translate_or(translator, 'common.alerts.til', 'til')
result += f" {til_label} {_expiry_label(expires, translator)}"
office = format_office(alert.get('office', ''), translator)
if office:
result += f" {office}"
return result
def extract_clock(time_str: str, translator: Any = None) -> Optional[str]:
"""Pull just the clock time out of a free-text NWS timestamp.
Args:
time_str: Free-text time ('December 17 at 6:00AM PST').
translator: Optional translator for AM/PM.
Returns:
Optional[str]: Localized clock time ('6AM', '6 утра'), or None when the
string carries no 12-hour clock.
"""
match = re.search(r'(\d+)(?::(\d+))?\s*(AM|PM)\b', time_str, re.IGNORECASE)
if match is None:
return None
hour, minutes, meridiem_raw = match.group(1), match.group(2), match.group(3).upper()
# ":00" reads as noise in a message this tight.
hour_text = f"{hour}:{minutes}" if minutes and minutes != '00' else hour
meridiem = translate_or(translator, f'common.alerts.{meridiem_raw.lower()}', meridiem_raw)
return translate_or(translator, 'common.alerts.time_12h', '{hour}{meridiem}',
hour=hour_text, meridiem=meridiem)
def _expiry_label(expires: str, translator: Any = None) -> str:
"""Render an expiry timestamp as compactly as it can be read.
Args:
expires: Expiry timestamp from the alert.
translator: Optional translator for month names and AM/PM.
Returns:
str: The clock time alone where we can find one, else a truncated
compact form.
"""
parsed = parse_display_time(expires, translator)
if parsed is not None:
# A dated expiry costs ~8 chars of a 130-byte budget; the time alone is
# unambiguous for alerts that expire within a day.
return parsed.time_only
# Same reasoning for the free-text form NWS puts in ATOM titles. Read the
# clock off the English source rather than re-parsing localized output.
clock = extract_clock(expires, translator)
if clock is not None:
return clock
return compact_time(expires, translator)[:15]
def format_alert_window(alert: dict[str, Any], translator: Any = None) -> str:
"""Render an alert's effective/expiry window ('from Dec 16 3PM til Dec 17 6AM').
Args:
alert: Alert dict with optional effective and expires timestamps.
translator: Optional translator for labels.
Returns:
str: Formatted window, or '' when the alert carries no timestamps.
"""
parts = []
effective = alert.get('effective', '')
if effective:
from_label = translate_or(translator, 'common.alerts.from', 'from')
parts.append(f"{from_label} {_window_label(effective, translator)}")
expires = alert.get('expires', '')
if expires:
til_label = translate_or(translator, 'common.alerts.til', 'til')
parts.append(f"{til_label} {_window_label(expires, translator)}")
return " ".join(parts)
def _window_label(time_str: str, translator: Any = None) -> str:
"""Render a timestamp for the dated window form.
Args:
time_str: Timestamp from the alert.
translator: Optional translator for month names and AM/PM.
Returns:
str: Dated form for ISO timestamps, else a truncated compact form.
"""
parsed = parse_display_time(time_str, translator)
if parsed is not None:
return parsed.dated
return compact_time(time_str, translator)[:25]
@@ -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.
+16 -2
View File
@@ -101,6 +101,20 @@ class BaseCommand(ABC):
# Load translated keywords after initialization
self._load_translated_keywords()
@property
def response_translator(self) -> Any:
"""The translator for the reply being built, or the bot default.
``respond_in_sender_language`` binds a per-message translator for the
duration of one reply, so helpers that format part of a response need
this rather than ``bot.translator`` — otherwise one line of a reply
comes back in the sender's language and the next in the bot's default.
Returns:
Any: Translator object, or None when the bot has none.
"""
return _response_translator.get() or getattr(self.bot, 'translator', None)
def translate(self, key: str, **kwargs: Any) -> str:
"""Translate a key using the bot's translator.
@@ -111,7 +125,7 @@ class BaseCommand(ABC):
Returns:
str: Translated string, or key if translation not found.
"""
translator = _response_translator.get() or getattr(self.bot, 'translator', None)
translator = self.response_translator
if translator is not None:
return translator.translate(key, **kwargs)
# Fallback if translator not available
@@ -126,7 +140,7 @@ class BaseCommand(ABC):
Returns:
Any: The value at the key path, or None if not found.
"""
translator = _response_translator.get() or getattr(self.bot, 'translator', None)
translator = self.response_translator
if translator is not None:
return translator.get_value(key)
return None
+41 -356
View File
@@ -15,6 +15,7 @@ import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .. import alert_format
from ..models import MeshMessage
from ..utils import (
format_temperature_high_low,
@@ -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
+100 -265
View File
@@ -31,6 +31,7 @@ except ImportError:
import contextlib
from .. import alert_format
from ..commands.rain_command import (
analyze_precip_nowcast,
decide_rain_notification,
@@ -131,10 +132,21 @@ class WeatherService(BaseServicePlugin):
# serialized without making the event loop wait on a threading lock.
self._api_session_lock = threading.Lock()
# Get temperature/wind units from config (for Open-Meteo)
self.temperature_unit = self.bot.config.get('Weather', 'temperature_unit', fallback='fahrenheit')
self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph')
self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch')
# Get temperature/wind units from config (for Open-Meteo). Normalized and
# validated the same way GlobalWxCommand does, so the unit also works as
# a translation key for its display label.
self.temperature_unit = self.bot.config.get('Weather', 'temperature_unit', fallback='fahrenheit').lower()
self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph').lower()
self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch').lower()
if self.temperature_unit not in ('fahrenheit', 'celsius'):
self.logger.warning(f"Invalid temperature_unit '{self.temperature_unit}', using 'fahrenheit'")
self.temperature_unit = 'fahrenheit'
if self.wind_speed_unit not in ('mph', 'kmh', 'ms', 'kn'):
self.logger.warning(f"Invalid wind_speed_unit '{self.wind_speed_unit}', using 'mph'")
self.wind_speed_unit = 'mph'
if self.precipitation_unit not in ('inch', 'mm'):
self.logger.warning(f"Invalid precipitation_unit '{self.precipitation_unit}', using 'inch'")
self.precipitation_unit = 'inch'
# Proactive rain nowcast ("rain incoming" push). Reuses the rain command's
# Open-Meteo 15-minutely logic for the bot's own position.
@@ -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
View File
@@ -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:
+1
View File
@@ -146,6 +146,7 @@ strict_optional = true
# New modules written with full type annotations get strict treatment.
[[tool.mypy.overrides]]
module = [
"modules.alert_format",
"modules.commands.schedule_command",
"modules.service_plugins.webhook_service",
"modules.service_plugins.base_service",
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Unit tests for the shared NWS alert formatter (modules.alert_format)."""
import pytest
from modules import alert_format as af
from modules.i18n import Translator
pytestmark = pytest.mark.unit
@pytest.fixture
def en():
return Translator("en")
@pytest.fixture
def ru():
return Translator("ru")
def alert(**overrides):
base = {
"event": "Flood Warning",
"event_type": "Warning",
"severity": "Severe",
"expires": "2026-06-28T18:00:00-07:00",
"office": "NWS Seattle WA",
"area_desc": "King County; Snohomish County",
}
base.update(overrides)
return base
class TestTranslateOr:
def test_present_key_translates(self, ru):
assert af.translate_or(ru, "common.unknown", "Unknown") == "Неизвестно"
def test_missing_key_uses_default(self, en):
assert af.translate_or(en, "no.such.key.at.all", "fallback") == "fallback"
def test_missing_key_formats_default(self, en):
assert af.translate_or(en, "no.such.key", "{n} left", n=3) == "3 left"
def test_no_translator_uses_default(self):
assert af.translate_or(None, "common.unknown", "Unknown") == "Unknown"
class TestEventTypeAbbrev:
@pytest.mark.parametrize(
("event_type", "expected"),
[("Warning", "Warn"), ("Watch", "Watch"), ("Advisory", "Adv"), ("Statement", "Stmt")],
)
def test_known_types(self, en, event_type, expected):
assert af.event_type_abbrev(event_type, en) == expected
def test_unknown_type_never_leaks_a_key(self, en):
# _parse_alert_entry emits "Unknown" for any title we cannot classify.
assert af.event_type_abbrev("Unknown", en) == "Unknown"
def test_unknown_type_never_leaks_a_key_localized(self, ru):
assert af.event_type_abbrev("Unknown", ru) == "Unknown"
def test_empty_type(self, en):
assert af.event_type_abbrev("", en) == ""
def test_localized_abbreviations_stay_abbreviations(self, ru):
# Full words would eat a quarter of the 130-byte budget at 2 bytes/char.
for event_type in ("Warning", "Watch", "Advisory", "Statement"):
assert len(af.event_type_abbrev(event_type, ru).encode("utf-8")) <= 16
class TestCompactTime:
def test_iso_english(self, en):
assert af.compact_time("2026-06-28T18:00:00-07:00", en) == "Jun 28 6PM"
def test_iso_midnight_and_noon(self, en):
assert af.compact_time("2026-06-28T00:30:00-07:00", en) == "Jun 28 12AM"
assert af.compact_time("2026-06-28T12:00:00-07:00", en) == "Jun 28 12PM"
def test_iso_russian_separates_hour_from_meridiem(self, ru):
# "6дня" is not Russian; the locale owns the separator.
assert af.compact_time("2026-06-28T18:00:00-07:00", ru) == "июн 28 6 дня"
def test_full_month_name_is_abbreviated(self, en):
assert af.compact_time("June 28 at 6:00AM", en) == "Jun 28 6AM"
def test_full_month_name_is_not_corrupted_mid_word(self, ru):
# Replacing the "Jun" inside "June" used to leave a stray Latin "e".
assert af.compact_time("June 28 at 6:00AM", ru) == "июн 28 6 утра"
@pytest.mark.parametrize("month", ["March", "April", "May", "July", "August"])
def test_no_latin_residue_in_any_month(self, ru, month):
out = af.compact_time(f"{month} 3 at 1:00PM", ru)
assert not any(ch.isascii() and ch.isalpha() for ch in out), out
def test_minutes_are_kept(self, en):
assert af.compact_time("December 16 at 3:12PM", en) == "Dec 16 3:12PM"
def test_empty_passes_through(self, en):
assert af.compact_time("", en) == ""
def test_unparseable_iso_falls_back_to_text_path(self, en):
assert af.compact_time("2026-13-45T99:00:00", en) == "2026-13-45T99:00:00"
def test_no_translator_is_english(self):
assert af.compact_time("2026-06-28T18:00:00-07:00") == "Jun 28 6PM"
class TestExtractClock:
def test_reads_clock_off_english_source(self, en):
assert af.extract_clock("December 17 at 6:00AM PST", en) == "6AM"
def test_keeps_real_minutes(self, en):
assert af.extract_clock("December 16 at 3:12PM PST", en) == "3:12PM"
def test_localized(self, ru):
assert af.extract_clock("December 17 at 6:00AM PST", ru) == "6 утра"
def test_no_clock_returns_none(self, en):
assert af.extract_clock("sometime tomorrow", en) is None
class TestFormatAlertCompact:
def test_english_iso_expiry_is_time_only(self, en):
assert af.format_alert_compact(alert(), en) == "🟠Flood Warning King til 6PM by NWS SEA"
def test_russian_iso_expiry_is_time_only(self, ru):
# The English-month check plus an (AM|PM) regex used to send every
# non-English locale down a mid-string truncation.
assert af.format_alert_compact(alert(), ru) == "🟠Flood Warning King до 6 дня от NWS SEA"
def test_free_text_expiry_is_time_only(self, en):
out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), en)
assert out == "🟠Flood Warning King til 6AM by NWS SEA"
def test_free_text_expiry_is_not_truncated_mid_word(self, ru):
out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), ru)
assert out == "🟠Flood Warning King до 6 утра от NWS SEA"
def test_unknown_event_type_never_leaks_a_key(self, en):
out = af.format_alert_compact(
alert(event="Hazardous", event_type="Unknown", severity="Minor",
expires="", office="", area_desc=""),
en,
)
assert out == "⚪Hazardous Unknown"
def test_summary_form_omits_details(self, en):
out = af.format_alert_compact(alert(), en, include_details=False)
assert out == "🟠Flood Warning Warn"
def test_location_can_be_suppressed(self, en):
out = af.format_alert_compact(alert(), en, include_location=False)
assert out == "🟠Flood Warning til 6PM by NWS SEA"
def test_redundant_event_type_is_dropped(self, en):
# "Flood Warning" already says "Warning".
assert "Warn " not in af.format_alert_compact(alert(), en)
def test_event_type_is_appended_when_not_redundant(self, en):
out = af.format_alert_compact(
alert(event="Dense Fog", event_type="Advisory", expires="", office="", area_desc=""), en
)
assert out == "🟠Dense Fog Adv"
def test_severity_emoji(self, en):
for severity, emoji in (("Extreme", "🔴"), ("Severe", "🟠"),
("Moderate", "🟡"), ("Minor", "⚪"), ("Nonsense", "⚪")):
out = af.format_alert_compact(
alert(severity=severity, expires="", office="", area_desc=""), en
)
assert out.startswith(emoji)
def test_fits_the_mesh_budget_in_both_locales(self, en, ru):
for translator in (en, ru):
out = af.format_alert_compact(alert(), translator)
assert len(out.encode("utf-8")) <= 130, out
class TestShortenEvent:
def test_short_name_untouched(self):
assert af.shorten_event("Dense Fog") == "Dense Fog"
def test_long_name_keeps_two_words(self):
assert af.shorten_event("Excessive Heat Warning Extended") == "Excessive Heat"
def test_single_long_word_is_cut(self):
assert af.shorten_event("Thunderstormageddon") == "Thunderstormage"
def test_summary_mode_keeps_one_word(self):
assert af.shorten_event("Excessive Heat Warning", limit=12, max_words=1) == "Excessive"
def test_none_limit_never_trims(self):
assert af.shorten_event("Excessive Heat Warning Extended", limit=None) == \
"Excessive Heat Warning Extended"
class TestLocation:
def test_strips_state(self):
assert af.first_location("Seattle, WA") == "Seattle"
def test_strips_county(self):
assert af.first_location("King County; Snohomish County") == "King"
def test_keeps_multiword_place(self):
assert af.first_location("Puget Sound") == "Puget Sound"
def test_truncates_long_name(self):
assert len(af.first_location("A" * 40)) == 20
def test_empty(self):
assert af.first_location("") == ""
class TestOffice:
def test_known_city(self, en):
assert af.format_office("NWS Seattle WA", en) == "by NWS SEA"
def test_localized_label(self, ru):
assert af.format_office("NWS Seattle WA", ru) == "от NWS SEA"
def test_single_token(self, en):
assert af.format_office("NWS", en) == "by NWS"
def test_empty(self, en):
assert af.format_office("", en) == ""
class TestCityAbbrev:
@pytest.mark.parametrize(("city", "expected"),
[("Seattle", "SEA"), ("Seattle WA", "SEA"), ("Portland", "PDX")])
def test_known(self, city, expected):
assert af.abbreviate_city_name(city) == expected
def test_initials_for_unknown_multiword(self):
assert af.abbreviate_city_name("Little Rock") == "LR"
def test_prefix_for_unknown_single_word(self):
assert af.abbreviate_city_name("Bozeman") == "BOZE"
def test_empty(self):
assert af.abbreviate_city_name("") == ""
class TestAlertWindow:
def test_both_ends(self, en):
out = af.format_alert_window(
{"effective": "2026-06-28T15:00:00-07:00", "expires": "2026-06-29T06:00:00-07:00"}, en
)
assert out == "from Jun 28 3PM til Jun 29 6AM"
def test_localized(self, ru):
out = af.format_alert_window({"expires": "2026-06-29T06:00:00-07:00"}, ru)
assert out == "до июн 29 6 утра"
def test_no_times(self, en):
assert af.format_alert_window({}, en) == ""
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Unit tests for !gwx display units — driven by [Weather] config, not by locale."""
import pytest
from modules.commands.alternatives.wx_international import (
HPA_TO_MMHG,
MI_TO_KM,
VISIBILITY_CAP_KM,
VISIBILITY_CAP_MI,
GlobalWxCommand,
)
from modules.i18n import Translator
@pytest.fixture
def gwx_bot(mock_bot):
mock_bot.config.add_section("Weather")
mock_bot.config.set("Weather", "weather_provider", "openmeteo")
mock_bot.config.set("Weather", "default_country", "US")
return mock_bot
def build(gwx_bot, *, temperature_unit="fahrenheit", language="en"):
gwx_bot.config.set("Weather", "temperature_unit", temperature_unit)
gwx_bot.translator = Translator(language)
return GlobalWxCommand(gwx_bot)
@pytest.mark.unit
class TestMetricDistance:
def test_fahrenheit_is_imperial(self, gwx_bot):
assert build(gwx_bot, temperature_unit="fahrenheit").metric_distance is False
def test_celsius_is_metric(self, gwx_bot):
assert build(gwx_bot, temperature_unit="celsius").metric_distance is True
def test_language_does_not_decide(self, gwx_bot):
# A Fahrenheit bot answering in Russian must not print kilometers next
# to Fahrenheit temperatures.
cmd = build(gwx_bot, temperature_unit="fahrenheit", language="ru")
assert cmd.metric_distance is False
def test_en_gb_with_celsius_gets_metric(self, gwx_bot):
cmd = build(gwx_bot, temperature_unit="celsius", language="en-GB")
assert cmd.metric_distance is True
def test_invalid_unit_falls_back_to_imperial(self, gwx_bot):
cmd = build(gwx_bot, temperature_unit="kelvin")
assert cmd.temperature_unit == "fahrenheit"
assert cmd.metric_distance is False
@pytest.mark.unit
class TestVisibilityStrings:
def test_imperial_string_says_miles_in_english(self):
assert Translator("en").translate("commands.gwx.visibility", value=12) == "👁️12mi"
def test_imperial_string_says_miles_in_russian(self):
# This key means miles in every catalog now that the unit comes from
# config; it used to say "км" in ru.
out = Translator("ru").translate("commands.gwx.visibility", value=12)
assert "км" not in out
assert "миль" in out
def test_metric_string_says_km(self):
assert Translator("en").translate("commands.gwx.visibility_km", value=12) == "👁️12km"
def test_caps_are_equivalent(self):
assert pytest.approx(VISIBILITY_CAP_KM, abs=0.3) == VISIBILITY_CAP_MI * MI_TO_KM
@pytest.mark.unit
class TestPressureUnitConvention:
@pytest.mark.parametrize("language", ["en", "en-GB", "de", "fr", "fr-CA",
"es", "nl", "pl", "pt", "pt-BR"])
def test_hpa_locales(self, language):
assert Translator(language).translate("commands.gwx.pressure_unit") == "hpa"
def test_russian_uses_mmhg(self):
assert Translator("ru").translate("commands.gwx.pressure_unit") == "mmhg"
@pytest.mark.parametrize("language", ["en", "de", "fr", "es", "nl", "pl", "pt"])
def test_hpa_locales_render_no_cyrillic(self, language):
out = Translator(language).translate("commands.gwx.pressure", value=1013)
assert not any("Ѐ" <= ch <= "ӿ" for ch in out), out
def test_english_mmhg_string_is_english(self):
# The en catalog held "мм рт. ст.", which every non-ru locale inherited.
out = Translator("en").translate("commands.gwx.pressure_mmhg", value=760)
assert out == "📊760mmHg"
def test_conversion_factor(self):
assert round(1013 * HPA_TO_MMHG) == 760
+3
View File
@@ -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
View File
@@ -223,6 +223,7 @@
"error": "Error getting weather data: {error}",
"alerts": "{count} alerts: {text}",
"tomorrow_not_available": "Tomorrow's forecast not available",
"hourly_not_available": "Hourly forecast not available",
"tomorrow_error": "Error formatting tomorrow's forecast",
"multiday_not_available": "{num_days}-day forecast not available",
"multiday_error": "Error formatting {num_days}-day forecast",
@@ -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"
}
}
}
}
+932
View File
@@ -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": "уз"
}
}
}
}