mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 13:24:09 +00:00
Enhance weather command functionality by adding support for multi-day and tomorrow forecasts. Updated parsing logic to handle forecast type options and improved response formatting. Integrated new weather data retrieval methods for both local and global commands, ensuring consistent user experience across different locales. Added localization support for new forecast messages in multiple languages.
This commit is contained in:
+22
-13
@@ -145,19 +145,28 @@ class CommandManager:
|
||||
content_lower = content.lower()
|
||||
|
||||
# Check for help requests first (special handling)
|
||||
if content_lower.startswith('help '):
|
||||
command_name = content_lower[5:].strip() # Remove "help " prefix
|
||||
help_text = self.get_help_for_command(command_name, message)
|
||||
# Format the help response with message data (same as other keywords)
|
||||
help_text = self.format_keyword_response(help_text, message)
|
||||
matches.append(('help', help_text))
|
||||
return matches
|
||||
elif content_lower == 'help':
|
||||
help_text = self.get_general_help()
|
||||
# Format the help response with message data (same as other keywords)
|
||||
help_text = self.format_keyword_response(help_text, message)
|
||||
matches.append(('help', help_text))
|
||||
return matches
|
||||
# Check both English "help" and translated help keywords
|
||||
help_keywords = ['help']
|
||||
if 'help' in self.commands:
|
||||
help_command = self.commands['help']
|
||||
if hasattr(help_command, 'keywords'):
|
||||
help_keywords = [k.lower() for k in help_command.keywords]
|
||||
|
||||
# Check if message starts with any help keyword
|
||||
for help_keyword in help_keywords:
|
||||
if content_lower.startswith(help_keyword + ' '):
|
||||
command_name = content_lower[len(help_keyword):].strip() # Remove help keyword prefix
|
||||
help_text = self.get_help_for_command(command_name, message)
|
||||
# Format the help response with message data (same as other keywords)
|
||||
help_text = self.format_keyword_response(help_text, message)
|
||||
matches.append(('help', help_text))
|
||||
return matches
|
||||
elif content_lower == help_keyword:
|
||||
help_text = self.get_general_help()
|
||||
# Format the help response with message data (same as other keywords)
|
||||
help_text = self.format_keyword_response(help_text, message)
|
||||
matches.append(('help', help_text))
|
||||
return matches
|
||||
|
||||
# Check all loaded plugins for matches
|
||||
for command_name, command in self.commands.items():
|
||||
|
||||
@@ -6,7 +6,7 @@ Provides worldwide weather information using Open-Meteo API
|
||||
|
||||
import re
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from geopy.geocoders import Nominatim
|
||||
from ..base_command import BaseCommand
|
||||
from ...models import MeshMessage
|
||||
@@ -115,20 +115,48 @@ class GlobalWxCommand(BaseCommand):
|
||||
"""Execute the weather command"""
|
||||
content = message.content.strip()
|
||||
|
||||
# Parse the command to extract location
|
||||
parts = content.split(maxsplit=1)
|
||||
# Parse the command to extract location and forecast type
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
await self.send_response(message, self.translate('commands.gwx.usage'))
|
||||
return True
|
||||
|
||||
location = parts[1].strip()
|
||||
# Check for forecast type options: "tomorrow", or a number 2-7
|
||||
forecast_type = "default"
|
||||
num_days = 7 # Default for multi-day forecast
|
||||
location_parts = parts[1:]
|
||||
|
||||
# Check last part for forecast type
|
||||
if len(location_parts) > 0:
|
||||
last_part = location_parts[-1].lower()
|
||||
if last_part == "tomorrow":
|
||||
forecast_type = "tomorrow"
|
||||
location_parts = location_parts[:-1]
|
||||
elif last_part.isdigit():
|
||||
# Check if it's a number between 2-7
|
||||
days = int(last_part)
|
||||
if 2 <= days <= 7:
|
||||
forecast_type = "multiday"
|
||||
num_days = days
|
||||
location_parts = location_parts[:-1]
|
||||
elif last_part in ["7day", "7-day"]:
|
||||
forecast_type = "multiday"
|
||||
num_days = 7
|
||||
location_parts = location_parts[:-1]
|
||||
|
||||
# Join remaining parts to handle "city, country" format
|
||||
location = ' '.join(location_parts).strip()
|
||||
|
||||
if not location:
|
||||
await self.send_response(message, self.translate('commands.gwx.usage'))
|
||||
return True
|
||||
|
||||
try:
|
||||
# Record execution for this user
|
||||
self._record_execution(message.sender_id)
|
||||
|
||||
# Get weather data for the location
|
||||
weather_data = await self.get_weather_for_location(location)
|
||||
weather_data = await self.get_weather_for_location(location, forecast_type, num_days)
|
||||
|
||||
# Check if we need to send multiple messages (for alerts)
|
||||
if isinstance(weather_data, tuple) and weather_data[0] == "multi_message":
|
||||
@@ -143,6 +171,9 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
# Send alerts
|
||||
await self.send_response(message, weather_data[2])
|
||||
elif forecast_type == "multiday":
|
||||
# Use message splitting for multi-day forecasts
|
||||
await self._send_multiday_forecast(message, weather_data)
|
||||
else:
|
||||
await self.send_response(message, weather_data)
|
||||
|
||||
@@ -153,8 +184,14 @@ class GlobalWxCommand(BaseCommand):
|
||||
await self.send_response(message, self.translate('commands.gwx.error', error=str(e)))
|
||||
return True
|
||||
|
||||
async def get_weather_for_location(self, location: str) -> str:
|
||||
"""Get weather data for any global location"""
|
||||
async def get_weather_for_location(self, location: str, forecast_type: str = "default", num_days: int = 7) -> str:
|
||||
"""Get weather data for any global location
|
||||
|
||||
Args:
|
||||
location: The location (city name, etc.)
|
||||
forecast_type: "default", "tomorrow", or "multiday"
|
||||
num_days: Number of days for multiday forecast (2-7)
|
||||
"""
|
||||
try:
|
||||
# Convert location to lat/lon with address details
|
||||
result = self.geocode_location(location)
|
||||
@@ -166,20 +203,26 @@ class GlobalWxCommand(BaseCommand):
|
||||
# Format location name for display
|
||||
location_display = self._format_location_display(address_info, geocode_result, location)
|
||||
|
||||
# Get weather forecast from Open-Meteo
|
||||
weather_text = self.get_open_meteo_weather(lat, lon)
|
||||
# Get weather forecast from Open-Meteo based on type
|
||||
if forecast_type == "tomorrow":
|
||||
weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="tomorrow")
|
||||
elif forecast_type == "multiday":
|
||||
weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="multiday", num_days=num_days)
|
||||
else:
|
||||
weather_text = self.get_open_meteo_weather(lat, lon)
|
||||
|
||||
# Check if it's an error (translated error message)
|
||||
error_fetching = self.translate('commands.gwx.error_fetching')
|
||||
if weather_text == error_fetching or weather_text == self.ERROR_FETCHING_DATA:
|
||||
return self.translate('commands.gwx.error_fetching_api')
|
||||
|
||||
# Check for severe weather warnings (Open-Meteo doesn't provide detailed alerts,
|
||||
# but we can infer from extreme conditions)
|
||||
alert_text = self._check_extreme_conditions(weather_text)
|
||||
|
||||
if alert_text:
|
||||
# Return multi-message format
|
||||
return ("multi_message", f"{location_display}: {weather_text}", alert_text)
|
||||
# Check for severe weather warnings (only for default forecast type)
|
||||
if forecast_type == "default":
|
||||
alert_text = self._check_extreme_conditions(weather_text)
|
||||
|
||||
if alert_text:
|
||||
# Return multi-message format
|
||||
return ("multi_message", f"{location_display}: {weather_text}", alert_text)
|
||||
|
||||
return f"{location_display}: {weather_text}"
|
||||
|
||||
@@ -342,12 +385,27 @@ class GlobalWxCommand(BaseCommand):
|
||||
}
|
||||
return state_map.get(state, state)
|
||||
|
||||
def get_open_meteo_weather(self, lat: float, lon: float) -> str:
|
||||
"""Get weather forecast from Open-Meteo API"""
|
||||
def get_open_meteo_weather(self, lat: float, lon: float, forecast_type: str = "default", num_days: int = 7) -> str:
|
||||
"""Get weather forecast from Open-Meteo API
|
||||
|
||||
Args:
|
||||
lat: Latitude
|
||||
lon: Longitude
|
||||
forecast_type: "default", "tomorrow", or "multiday"
|
||||
num_days: Number of days for multiday forecast (2-7)
|
||||
"""
|
||||
try:
|
||||
# Open-Meteo API endpoint with current weather and forecast
|
||||
api_url = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
# Determine forecast_days based on type
|
||||
if forecast_type == "multiday":
|
||||
forecast_days = min(num_days, 7) # Open-Meteo supports up to 7 days
|
||||
elif forecast_type == "tomorrow":
|
||||
forecast_days = 2 # Need today and tomorrow
|
||||
else:
|
||||
forecast_days = 2 # Default
|
||||
|
||||
params = {
|
||||
'latitude': lat,
|
||||
'longitude': lon,
|
||||
@@ -358,9 +416,24 @@ class GlobalWxCommand(BaseCommand):
|
||||
'wind_speed_unit': self.wind_speed_unit,
|
||||
'precipitation_unit': self.precipitation_unit,
|
||||
'timezone': 'auto',
|
||||
'forecast_days': 2
|
||||
'forecast_days': forecast_days
|
||||
}
|
||||
|
||||
# For tomorrow or multiday, return raw data for formatting
|
||||
if forecast_type in ["tomorrow", "multiday"]:
|
||||
response = requests.get(api_url, params=params, timeout=self.url_timeout)
|
||||
|
||||
if not response.ok:
|
||||
self.logger.warning(f"Error fetching weather from Open-Meteo: {response.status_code}")
|
||||
return self.translate('commands.gwx.error_fetching')
|
||||
|
||||
data = response.json()
|
||||
|
||||
if forecast_type == "tomorrow":
|
||||
return self.format_tomorrow_forecast(data)
|
||||
elif forecast_type == "multiday":
|
||||
return self.format_multiday_forecast(data, num_days)
|
||||
|
||||
response = requests.get(api_url, params=params, timeout=self.url_timeout)
|
||||
|
||||
if not response.ok:
|
||||
@@ -505,6 +578,187 @@ class GlobalWxCommand(BaseCommand):
|
||||
self.logger.error(f"Error fetching Open-Meteo weather: {e}")
|
||||
return self.translate('commands.gwx.error_fetching')
|
||||
|
||||
def format_tomorrow_forecast(self, data: dict) -> str:
|
||||
"""Format a detailed forecast for tomorrow"""
|
||||
try:
|
||||
daily = data.get('daily', {})
|
||||
if not daily or len(daily.get('temperature_2m_max', [])) < 2:
|
||||
return self.translate('commands.gwx.tomorrow_not_available')
|
||||
|
||||
temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C"
|
||||
tomorrow_high = int(daily['temperature_2m_max'][1])
|
||||
tomorrow_low = int(daily['temperature_2m_min'][1])
|
||||
tomorrow_code = daily['weather_code'][1]
|
||||
tomorrow_emoji = self._get_weather_emoji(tomorrow_code)
|
||||
tomorrow_desc = self._get_weather_description(tomorrow_code)
|
||||
|
||||
# Get wind info if available
|
||||
wind_info = ""
|
||||
if len(daily.get('wind_speed_10m_max', [])) > 1:
|
||||
wind_speed = int(daily['wind_speed_10m_max'][1])
|
||||
if wind_speed >= 3:
|
||||
wind_info = f" {wind_speed}"
|
||||
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}"
|
||||
|
||||
# Get precipitation probability
|
||||
precip_info = ""
|
||||
if len(daily.get('precipitation_probability_max', [])) > 1:
|
||||
precip_prob = daily['precipitation_probability_max'][1]
|
||||
if precip_prob >= 30:
|
||||
precip_info = f" 🌦️{precip_prob}%"
|
||||
|
||||
tomorrow_period = self.translate('commands.gwx.periods.tomorrow')
|
||||
return f"{tomorrow_period}: {tomorrow_emoji}{tomorrow_desc} {tomorrow_high}{temp_symbol}/{tomorrow_low}{temp_symbol}{wind_info}{precip_info}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting tomorrow forecast: {e}")
|
||||
return self.translate('commands.gwx.tomorrow_error')
|
||||
|
||||
def format_multiday_forecast(self, data: dict, num_days: int = 7) -> str:
|
||||
"""Format a less detailed multi-day forecast summary"""
|
||||
try:
|
||||
daily = data.get('daily', {})
|
||||
if not daily:
|
||||
return self.translate('commands.gwx.multiday_not_available', num_days=num_days)
|
||||
|
||||
temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C"
|
||||
temps_max = daily.get('temperature_2m_max', [])
|
||||
temps_min = daily.get('temperature_2m_min', [])
|
||||
weather_codes = daily.get('weather_code', [])
|
||||
|
||||
if len(temps_max) < num_days + 1: # +1 because index 0 is today
|
||||
num_days = len(temps_max) - 1
|
||||
|
||||
# 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'
|
||||
}
|
||||
|
||||
parts = []
|
||||
today = datetime.now()
|
||||
|
||||
# Start from tomorrow (index 1)
|
||||
for i in range(1, min(num_days + 1, len(temps_max))):
|
||||
day_date = today + timedelta(days=i)
|
||||
day_name = day_date.strftime('%A')
|
||||
day_abbrev = day_abbrev_map.get(day_name, day_name[:2])
|
||||
|
||||
high = int(temps_max[i])
|
||||
low = int(temps_min[i])
|
||||
code = weather_codes[i] if i < len(weather_codes) else 0
|
||||
emoji = self._get_weather_emoji(code)
|
||||
desc = self._get_weather_description(code)
|
||||
|
||||
# Abbreviate description if needed
|
||||
desc_short = desc
|
||||
if len(desc) > 20:
|
||||
desc_short = desc[:17] + "..."
|
||||
|
||||
parts.append(f"{day_abbrev}: {emoji}{desc_short} {high}{temp_symbol}/{low}{temp_symbol}")
|
||||
|
||||
if not parts:
|
||||
return self.translate('commands.gwx.multiday_not_available', num_days=num_days)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting {num_days}-day forecast: {e}")
|
||||
return self.translate('commands.gwx.multiday_error', num_days=num_days)
|
||||
|
||||
def _count_display_width(self, text: str) -> int:
|
||||
"""Count display width of text, accounting for emojis which may take 2 display units"""
|
||||
import re
|
||||
# Count regular characters
|
||||
width = len(text)
|
||||
# Emojis typically take 2 display units in terminals/clients
|
||||
# Count emoji characters (basic emoji pattern)
|
||||
emoji_pattern = re.compile(
|
||||
"["
|
||||
"\U0001F600-\U0001F64F" # emoticons
|
||||
"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
"\U0001F1E0-\U0001F1FF" # flags
|
||||
"\U00002702-\U000027B0" # dingbats
|
||||
"\U000024C2-\U0001F251" # enclosed characters
|
||||
"]+",
|
||||
flags=re.UNICODE
|
||||
)
|
||||
emoji_matches = emoji_pattern.findall(text)
|
||||
# Each emoji sequence adds 1 extra width unit (since len() already counts it as 1)
|
||||
# So we add 1 for each emoji sequence to account for display width
|
||||
width += len(emoji_matches)
|
||||
return width
|
||||
|
||||
async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str):
|
||||
"""Send multi-day forecast response, splitting into multiple messages if needed"""
|
||||
import asyncio
|
||||
|
||||
lines = forecast_text.split('\n')
|
||||
|
||||
# Remove empty lines
|
||||
lines = [line.strip() for line in lines if line.strip()]
|
||||
|
||||
if not lines:
|
||||
return
|
||||
|
||||
# If single line and under 130 chars, send as-is
|
||||
if self._count_display_width(forecast_text) <= 130:
|
||||
await self.send_response(message, forecast_text)
|
||||
return
|
||||
|
||||
# Multi-line message - try to fit as many days as possible in one message
|
||||
# Only split when necessary (message would exceed 130 chars)
|
||||
current_message = ""
|
||||
message_count = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check if adding this line would exceed 130 characters (using display width)
|
||||
if current_message:
|
||||
test_message = current_message + "\n" + line
|
||||
else:
|
||||
test_message = line
|
||||
|
||||
# Only split if message would exceed 130 chars (using display width)
|
||||
if self._count_display_width(test_message) > 130:
|
||||
# Send current message and start new one
|
||||
if current_message:
|
||||
await self.send_response(message, current_message)
|
||||
message_count += 1
|
||||
# Wait between messages (same as other commands)
|
||||
if i < len(lines):
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
current_message = line
|
||||
else:
|
||||
# Single line is too long, send it anyway (will be truncated by bot)
|
||||
await self.send_response(message, line)
|
||||
message_count += 1
|
||||
if i < len(lines) - 1:
|
||||
await asyncio.sleep(2.0)
|
||||
current_message = ""
|
||||
else:
|
||||
# Add line to current message (fits within 130 chars)
|
||||
if current_message:
|
||||
current_message += "\n" + line
|
||||
else:
|
||||
current_message = line
|
||||
|
||||
# Send the last message if there's content
|
||||
if current_message:
|
||||
await self.send_response(message, current_message)
|
||||
|
||||
def _degrees_to_direction(self, degrees: float) -> str:
|
||||
"""Convert wind direction in degrees to compass direction with emoji"""
|
||||
if degrees is None:
|
||||
@@ -534,36 +788,36 @@ class GlobalWxCommand(BaseCommand):
|
||||
# If translation returned the key (not found), try fallback
|
||||
if description == key:
|
||||
# Fallback to hardcoded descriptions
|
||||
weather_codes = {
|
||||
0: "Clear",
|
||||
1: "Mostly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Foggy",
|
||||
48: "Foggy",
|
||||
51: "Light Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Heavy Drizzle",
|
||||
56: "Light Freezing Drizzle",
|
||||
57: "Freezing Drizzle",
|
||||
61: "Light Rain",
|
||||
63: "Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Light Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Light Snow",
|
||||
73: "Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Light Showers",
|
||||
81: "Showers",
|
||||
82: "Heavy Showers",
|
||||
85: "Light Snow Showers",
|
||||
86: "Snow Showers",
|
||||
95: "Thunderstorm",
|
||||
96: "T-Storm w/Hail",
|
||||
99: "Severe T-Storm"
|
||||
}
|
||||
weather_codes = {
|
||||
0: "Clear",
|
||||
1: "Mostly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Foggy",
|
||||
48: "Foggy",
|
||||
51: "Light Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Heavy Drizzle",
|
||||
56: "Light Freezing Drizzle",
|
||||
57: "Freezing Drizzle",
|
||||
61: "Light Rain",
|
||||
63: "Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Light Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Light Snow",
|
||||
73: "Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Light Showers",
|
||||
81: "Showers",
|
||||
82: "Heavy Showers",
|
||||
85: "Light Snow Showers",
|
||||
86: "Snow Showers",
|
||||
95: "Thunderstorm",
|
||||
96: "T-Storm w/Hail",
|
||||
99: "Severe T-Storm"
|
||||
}
|
||||
return weather_codes.get(code, self.translate('commands.gwx.weather_descriptions.unknown'))
|
||||
|
||||
return description
|
||||
|
||||
@@ -191,6 +191,7 @@ class BaseCommand(ABC):
|
||||
def _load_translated_keywords(self):
|
||||
"""Load translated keywords from translation files"""
|
||||
if not hasattr(self.bot, 'translator'):
|
||||
self.logger.debug(f"Translator not available for {self.name}, skipping keyword loading")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -200,13 +201,19 @@ class BaseCommand(ABC):
|
||||
|
||||
if translated_keywords and isinstance(translated_keywords, list):
|
||||
# Merge translated keywords with original keywords (avoid duplicates)
|
||||
original_count = len(self.keywords)
|
||||
all_keywords = list(self.keywords) # Start with original
|
||||
for translated_keyword in translated_keywords:
|
||||
if translated_keyword not in all_keywords:
|
||||
all_keywords.append(translated_keyword)
|
||||
self.keywords = all_keywords
|
||||
added_count = len(self.keywords) - original_count
|
||||
if added_count > 0:
|
||||
self.logger.debug(f"Loaded {added_count} translated keyword(s) for {self.name}: {self.keywords}")
|
||||
else:
|
||||
self.logger.debug(f"No translated keywords found for {self.name} (key: {key})")
|
||||
except Exception as e:
|
||||
# Silently fail - if translations aren't available, use original keywords
|
||||
# Log the error for debugging
|
||||
self.logger.debug(f"Could not load translated keywords for {self.name}: {e}")
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
|
||||
+556
-71
@@ -8,7 +8,7 @@ import re
|
||||
import json
|
||||
import requests
|
||||
import xml.dom.minidom
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from geopy.geocoders import Nominatim
|
||||
import maidenhead as mh
|
||||
from .base_command import BaseCommand
|
||||
@@ -107,15 +107,43 @@ class WxCommand(BaseCommand):
|
||||
"""Execute the weather command"""
|
||||
content = message.content.strip()
|
||||
|
||||
# Parse the command to extract location
|
||||
# Parse the command to extract location and forecast type
|
||||
# Support formats: "wx 12345", "wx seattle", "wx paris, tx", "weather everett", "wxa bellingham"
|
||||
# New formats: "wx 12345 tomorrow", "wx 12345 7", "wx 12345 7day"
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
await self.send_response(message, self.translate('commands.wx.usage'))
|
||||
return True
|
||||
|
||||
# Join all parts after the command to handle "city, state" format
|
||||
location = ' '.join(parts[1:]).strip()
|
||||
# Check for forecast type options: "tomorrow", or a number 2-7
|
||||
forecast_type = "default"
|
||||
num_days = 7 # Default for multi-day forecast
|
||||
location_parts = parts[1:]
|
||||
|
||||
# Check last part for forecast type
|
||||
if len(location_parts) > 0:
|
||||
last_part = location_parts[-1].lower()
|
||||
if last_part == "tomorrow":
|
||||
forecast_type = "tomorrow"
|
||||
location_parts = location_parts[:-1]
|
||||
elif last_part.isdigit():
|
||||
# Check if it's a number between 2-7
|
||||
days = int(last_part)
|
||||
if 2 <= days <= 7:
|
||||
forecast_type = "multiday"
|
||||
num_days = days
|
||||
location_parts = location_parts[:-1]
|
||||
elif last_part in ["7day", "7-day"]:
|
||||
forecast_type = "multiday"
|
||||
num_days = 7
|
||||
location_parts = location_parts[:-1]
|
||||
|
||||
# Join remaining parts to handle "city, state" format
|
||||
location = ' '.join(location_parts).strip()
|
||||
|
||||
if not location:
|
||||
await self.send_response(message, self.translate('commands.wx.usage'))
|
||||
return True
|
||||
|
||||
# Check if it's a zipcode (5 digits) or city name
|
||||
if re.match(r'^\d{5}$', location):
|
||||
@@ -130,7 +158,7 @@ class WxCommand(BaseCommand):
|
||||
self._record_execution(message.sender_id)
|
||||
|
||||
# Get weather data for the location
|
||||
weather_data = await self.get_weather_for_location(location, location_type)
|
||||
weather_data = await self.get_weather_for_location(location, location_type, forecast_type, num_days)
|
||||
|
||||
# Check if we need to send multiple messages
|
||||
if isinstance(weather_data, tuple) and weather_data[0] == "multi_message":
|
||||
@@ -148,6 +176,9 @@ class WxCommand(BaseCommand):
|
||||
alert_text = weather_data[2]
|
||||
alert_count = weather_data[3]
|
||||
await self.send_response(message, f"{alert_count} alerts: {alert_text}")
|
||||
elif forecast_type == "multiday":
|
||||
# Use message splitting for multi-day forecasts
|
||||
await self._send_multiday_forecast(message, weather_data)
|
||||
else:
|
||||
# Send single message as usual
|
||||
await self.send_response(message, weather_data)
|
||||
@@ -159,14 +190,22 @@ class WxCommand(BaseCommand):
|
||||
await self.send_response(message, self.translate('commands.wx.error', error=str(e)))
|
||||
return True
|
||||
|
||||
async def get_weather_for_location(self, location: str, location_type: str) -> str:
|
||||
"""Get weather data for a location (zipcode or city)"""
|
||||
async def get_weather_for_location(self, location: str, location_type: str, forecast_type: str = "default", num_days: int = 7) -> str:
|
||||
"""Get weather data for a location (zipcode or city)
|
||||
|
||||
Args:
|
||||
location: The location (zipcode or city name)
|
||||
location_type: "zipcode" or "city"
|
||||
forecast_type: "default", "tomorrow", or "multiday"
|
||||
num_days: Number of days for multiday forecast (2-7)
|
||||
"""
|
||||
try:
|
||||
# Convert location to lat/lon
|
||||
if location_type == "zipcode":
|
||||
lat, lon = self.zipcode_to_lat_lon(location)
|
||||
if lat is None or lon is None:
|
||||
return self.translate('commands.wx.no_location_zipcode', location=location)
|
||||
address_info = None
|
||||
else: # city
|
||||
result = self.city_to_lat_lon(location)
|
||||
if len(result) == 3:
|
||||
@@ -216,11 +255,6 @@ class WxCommand(BaseCommand):
|
||||
abbrev_to_full_map = {v: k for k, v in state_abbrev_map.items()}
|
||||
default_state_full = abbrev_to_full_map.get(self.default_state, self.default_state)
|
||||
|
||||
# Get weather forecast
|
||||
weather, points_data = self.get_noaa_weather(lat, lon)
|
||||
if weather == self.ERROR_FETCHING_DATA:
|
||||
return self.translate('commands.wx.error_fetching')
|
||||
|
||||
# Add location info if city is in a different state than default
|
||||
location_prefix = ""
|
||||
if location_type == "city" and address_info:
|
||||
@@ -230,23 +264,40 @@ class WxCommand(BaseCommand):
|
||||
if states_different:
|
||||
location_prefix = f"{actual_city}, {actual_state}: "
|
||||
|
||||
# Try to get additional current conditions data
|
||||
current_conditions = self.get_current_conditions(points_data)
|
||||
if current_conditions and len(weather) < 120:
|
||||
weather = f"{weather} {current_conditions}"
|
||||
# Get weather forecast based on type
|
||||
if forecast_type == "tomorrow":
|
||||
forecast_periods, points_data = self.get_noaa_weather(lat, lon, return_periods=True)
|
||||
if forecast_periods == self.ERROR_FETCHING_DATA:
|
||||
return self.translate('commands.wx.error_fetching')
|
||||
weather = self.format_tomorrow_forecast(forecast_periods)
|
||||
elif forecast_type == "multiday":
|
||||
forecast_periods, points_data = self.get_noaa_weather(lat, lon, return_periods=True)
|
||||
if forecast_periods == self.ERROR_FETCHING_DATA:
|
||||
return self.translate('commands.wx.error_fetching')
|
||||
weather = self.format_multiday_forecast(forecast_periods, num_days)
|
||||
else: # default
|
||||
weather, points_data = self.get_noaa_weather(lat, lon)
|
||||
if weather == self.ERROR_FETCHING_DATA:
|
||||
return self.translate('commands.wx.error_fetching')
|
||||
|
||||
# Try to get additional current conditions data
|
||||
current_conditions = self.get_current_conditions(points_data)
|
||||
if current_conditions and self._count_display_width(weather) < 120:
|
||||
weather = f"{weather} {current_conditions}"
|
||||
|
||||
# Get weather alerts
|
||||
alerts_result = self.get_weather_alerts_noaa(lat, lon)
|
||||
if alerts_result == self.ERROR_FETCHING_DATA:
|
||||
alerts_info = None
|
||||
elif alerts_result == self.NO_ALERTS:
|
||||
alerts_info = None
|
||||
else:
|
||||
full_alert_text, abbreviated_alert_text, alert_count = alerts_result
|
||||
if alert_count > 0:
|
||||
# Always send weather first, then alerts in separate message
|
||||
self.logger.info(f"Found {alert_count} alerts - using two-message mode")
|
||||
return ("multi_message", f"{location_prefix}{weather}", full_alert_text, alert_count)
|
||||
# Get weather alerts (only for default forecast type to avoid cluttering)
|
||||
if forecast_type == "default":
|
||||
alerts_result = self.get_weather_alerts_noaa(lat, lon)
|
||||
if alerts_result == self.ERROR_FETCHING_DATA:
|
||||
alerts_info = None
|
||||
elif alerts_result == self.NO_ALERTS:
|
||||
alerts_info = None
|
||||
else:
|
||||
full_alert_text, abbreviated_alert_text, alert_count = alerts_result
|
||||
if alert_count > 0:
|
||||
# Always send weather first, then alerts in separate message
|
||||
self.logger.info(f"Found {alert_count} alerts - using two-message mode")
|
||||
return ("multi_message", f"{location_prefix}{weather}", full_alert_text, alert_count)
|
||||
|
||||
return f"{location_prefix}{weather}"
|
||||
|
||||
@@ -378,8 +429,17 @@ class WxCommand(BaseCommand):
|
||||
self.logger.error(f"Error geocoding city {city}: {e}")
|
||||
return None, None, None
|
||||
|
||||
def get_noaa_weather(self, lat: float, lon: float) -> tuple:
|
||||
"""Get weather forecast from NOAA and return both weather string and points data"""
|
||||
def get_noaa_weather(self, lat: float, lon: float, return_periods: bool = False) -> tuple:
|
||||
"""Get weather forecast from NOAA and return both weather string and points data
|
||||
|
||||
Args:
|
||||
lat: Latitude
|
||||
lon: Longitude
|
||||
return_periods: If True, return forecast periods array instead of formatted string
|
||||
|
||||
Returns:
|
||||
Tuple of (weather_string_or_periods, points_data)
|
||||
"""
|
||||
try:
|
||||
# Get weather data from NOAA
|
||||
weather_api = f"https://api.weather.gov/points/{lat},{lon}"
|
||||
@@ -397,14 +457,20 @@ class WxCommand(BaseCommand):
|
||||
forecast_data = requests.get(forecast_url, timeout=self.url_timeout)
|
||||
if not forecast_data.ok:
|
||||
self.logger.warning("Error fetching weather forecast from NOAA")
|
||||
return self.ERROR_FETCHING_DATA
|
||||
return self.ERROR_FETCHING_DATA, None
|
||||
|
||||
forecast_json = forecast_data.json()
|
||||
forecast = forecast_json['properties']['periods']
|
||||
|
||||
# If return_periods is True, return the periods array directly
|
||||
if return_periods:
|
||||
if not forecast:
|
||||
return self.ERROR_FETCHING_DATA, None
|
||||
return forecast, weather_json
|
||||
|
||||
# Format the forecast - focus on current conditions and key info
|
||||
if not forecast:
|
||||
return "No forecast data available"
|
||||
return "No forecast data available", weather_json
|
||||
|
||||
current = forecast[0]
|
||||
day_name = self.abbreviate_noaa(current['name'])
|
||||
@@ -433,75 +499,181 @@ class WxCommand(BaseCommand):
|
||||
if wind_dir:
|
||||
weather += f" {wind_dir}{wind_num}"
|
||||
|
||||
# Add humidity if available and space allows
|
||||
if humidity and len(weather) < 90:
|
||||
# Add humidity if available and space allows (using display width)
|
||||
if humidity and self._count_display_width(weather) < 90:
|
||||
weather += f" {humidity}%RH"
|
||||
|
||||
# Add precipitation chance if available and space allows
|
||||
if precip_chance and len(weather) < 100:
|
||||
if precip_chance and self._count_display_width(weather) < 100:
|
||||
weather += f" 🌦️{precip_chance}%"
|
||||
|
||||
# Add UV index if available and space allows
|
||||
uv_index = self.extract_uv_index(detailed_forecast)
|
||||
if uv_index and len(weather) < 110:
|
||||
if uv_index and self._count_display_width(weather) < 110:
|
||||
weather += f" UV{uv_index}"
|
||||
|
||||
# Add dew point if available and space allows
|
||||
dew_point = self.extract_dew_point(detailed_forecast)
|
||||
if dew_point and len(weather) < 120:
|
||||
if dew_point and self._count_display_width(weather) < 120:
|
||||
weather += f" 💧{dew_point}°"
|
||||
|
||||
# Add visibility if available and space allows
|
||||
visibility = self.extract_visibility(detailed_forecast)
|
||||
if visibility and len(weather) < 130:
|
||||
if visibility and self._count_display_width(weather) < 130:
|
||||
weather += f" 👁️{visibility}mi"
|
||||
|
||||
# Add precipitation probability if available and space allows
|
||||
precip_prob = self.extract_precip_probability(detailed_forecast)
|
||||
if precip_prob and len(weather) < 140:
|
||||
if precip_prob and self._count_display_width(weather) < 140:
|
||||
weather += f" 🌦️{precip_prob}%"
|
||||
|
||||
# Add wind gusts if available and space allows
|
||||
wind_gusts = self.extract_wind_gusts(detailed_forecast)
|
||||
if wind_gusts and len(weather) < 140:
|
||||
if wind_gusts and self._count_display_width(weather) < 140:
|
||||
weather += f" 💨{wind_gusts}"
|
||||
|
||||
# Add next with high/low if available
|
||||
if len(forecast) > 1:
|
||||
next_period = forecast[1]
|
||||
next_name = next_period.get('name', 'Next')
|
||||
next_name_abbrev = self.abbreviate_noaa(next_name)
|
||||
next_temp = next_period.get('temperature', '')
|
||||
next_short = next_period.get('shortForecast', '')
|
||||
next_detailed = next_period.get('detailedForecast', '')
|
||||
next_wind_speed = next_period.get('windSpeed', '')
|
||||
next_wind_direction = next_period.get('windDirection', '')
|
||||
# Add next period (Tonight) and Tomorrow if available
|
||||
# First, find Tonight and Tomorrow periods
|
||||
tonight_period = None
|
||||
tomorrow_period = None
|
||||
current_period_name = current.get('name', '').lower()
|
||||
is_current_tonight = 'tonight' in current_period_name
|
||||
|
||||
for i, period in enumerate(forecast):
|
||||
period_name = period.get('name', '').lower()
|
||||
if 'tonight' in period_name and tonight_period is None:
|
||||
tonight_period = (i, period)
|
||||
elif 'tomorrow' in period_name and tomorrow_period is None:
|
||||
tomorrow_period = (i, period)
|
||||
|
||||
# If current is Tonight and we haven't found Tomorrow yet, look for next day's periods
|
||||
if is_current_tonight and not tomorrow_period:
|
||||
# Look for periods after Tonight (next day)
|
||||
for i, period in enumerate(forecast):
|
||||
if i > 0: # Skip current period
|
||||
period_name = period.get('name', '').lower()
|
||||
# Look for tomorrow, next day, or day names
|
||||
if any(word in period_name for word in ['tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']):
|
||||
tomorrow_period = (i, period)
|
||||
break
|
||||
|
||||
# Add Tonight if it's the immediate next period (and current is not already Tonight)
|
||||
if tonight_period and tonight_period[0] == 1 and not is_current_tonight:
|
||||
period = tonight_period[1]
|
||||
period_name = self.abbreviate_noaa(period.get('name', 'Tonight'))
|
||||
period_temp = period.get('temperature', '')
|
||||
period_short = period.get('shortForecast', '')
|
||||
period_detailed = period.get('detailedForecast', '')
|
||||
period_wind_speed = period.get('windSpeed', '')
|
||||
period_wind_direction = period.get('windDirection', '')
|
||||
|
||||
if next_temp and next_short:
|
||||
# Try to get high/low for next
|
||||
next_high_low = self.extract_high_low(next_detailed)
|
||||
if period_temp and period_short:
|
||||
# Try to get high/low
|
||||
period_high_low = self.extract_high_low(period_detailed)
|
||||
|
||||
next_emoji = self.get_weather_emoji(next_short)
|
||||
if next_high_low:
|
||||
next_str = f" | {next_name_abbrev}: {next_emoji}{next_short} {next_high_low}"
|
||||
period_emoji = self.get_weather_emoji(period_short)
|
||||
if period_high_low:
|
||||
period_str = f" | {period_name}: {period_emoji}{period_short} {period_high_low}"
|
||||
else:
|
||||
next_str = f" | {next_name_abbrev}: {next_emoji}{next_short} {next_temp}°"
|
||||
period_str = f" | {period_name}: {period_emoji}{period_short} {period_temp}°"
|
||||
|
||||
# Add next wind info if space allows
|
||||
if next_wind_speed and next_wind_direction and len(weather + next_str) < 120:
|
||||
import re
|
||||
wind_match = re.search(r'(\d+)', next_wind_speed)
|
||||
if wind_match:
|
||||
wind_num = wind_match.group(1)
|
||||
wind_dir = self.abbreviate_wind_direction(next_wind_direction)
|
||||
if wind_dir:
|
||||
wind_info = f" {wind_dir}{wind_num}"
|
||||
if len(weather + next_str + wind_info) <= 130:
|
||||
next_str += wind_info
|
||||
# Add wind info if space allows (using display width)
|
||||
if period_wind_speed and period_wind_direction:
|
||||
test_str = weather + period_str
|
||||
if self._count_display_width(test_str) < 120:
|
||||
import re
|
||||
wind_match = re.search(r'(\d+)', period_wind_speed)
|
||||
if wind_match:
|
||||
wind_num = wind_match.group(1)
|
||||
wind_dir = self.abbreviate_wind_direction(period_wind_direction)
|
||||
if wind_dir:
|
||||
wind_info = f" {wind_dir}{wind_num}"
|
||||
if self._count_display_width(test_str + wind_info) <= 130:
|
||||
period_str += wind_info
|
||||
|
||||
# Only add if we have space
|
||||
if len(weather + next_str) <= 130: # Leave room for alerts
|
||||
weather += next_str
|
||||
# Only add if we have space (using display width)
|
||||
if self._count_display_width(weather + period_str) <= 130: # Leave room for alerts
|
||||
weather += period_str
|
||||
|
||||
# Always try to add Tomorrow if available (especially if current is Tonight)
|
||||
# Prioritize adding Tomorrow when current is Tonight to use more of the 130 char limit
|
||||
if tomorrow_period:
|
||||
period = tomorrow_period[1]
|
||||
period_name = self.abbreviate_noaa(period.get('name', 'Tomorrow'))
|
||||
period_temp = period.get('temperature', '')
|
||||
period_short = period.get('shortForecast', '')
|
||||
period_detailed = period.get('detailedForecast', '')
|
||||
period_wind_speed = period.get('windSpeed', '')
|
||||
period_wind_direction = period.get('windDirection', '')
|
||||
|
||||
if period_temp and period_short:
|
||||
# Try to get high/low for tomorrow
|
||||
period_high_low = self.extract_high_low(period_detailed)
|
||||
|
||||
# Abbreviate forecast text if it's too long (especially when current is Tonight)
|
||||
abbreviated_forecast = period_short
|
||||
if is_current_tonight and len(period_short) > 20:
|
||||
# Try to shorten forecast text to fit more info
|
||||
# Remove transitional words and keep meaningful conditions
|
||||
words = period_short.split()
|
||||
# Transitional words to skip
|
||||
transitions = {'then', 'and', 'or', 'becoming', 'followed', 'by', 'with'}
|
||||
|
||||
# If there's a "then" pattern, take first condition and last significant condition
|
||||
if 'then' in words:
|
||||
then_index = words.index('then')
|
||||
# Take first condition (before "then")
|
||||
first_part = words[:then_index]
|
||||
# Take last significant condition (after "then", skip small words)
|
||||
if then_index + 1 < len(words):
|
||||
last_part = [w for w in words[then_index + 1:] if w.lower() not in transitions]
|
||||
# Combine: first condition + last significant condition (max 2 words)
|
||||
if last_part:
|
||||
abbreviated_forecast = ' '.join(first_part)
|
||||
if len(last_part) <= 2:
|
||||
abbreviated_forecast += ' ' + ' '.join(last_part)
|
||||
else:
|
||||
# Take last 2 words of the last part
|
||||
abbreviated_forecast += ' ' + ' '.join(last_part[-2:])
|
||||
else:
|
||||
abbreviated_forecast = ' '.join(first_part)
|
||||
else:
|
||||
abbreviated_forecast = ' '.join(first_part)
|
||||
else:
|
||||
# Filter out transitional words and take first meaningful words
|
||||
meaningful_words = [w for w in words if w.lower() not in transitions]
|
||||
if len(meaningful_words) > 3:
|
||||
abbreviated_forecast = ' '.join(meaningful_words[:3])
|
||||
else:
|
||||
abbreviated_forecast = ' '.join(meaningful_words)
|
||||
|
||||
period_emoji = self.get_weather_emoji(period_short)
|
||||
if period_high_low:
|
||||
period_str = f" | {period_name}: {period_emoji}{abbreviated_forecast} {period_high_low}"
|
||||
else:
|
||||
period_str = f" | {period_name}: {period_emoji}{abbreviated_forecast} {period_temp}°"
|
||||
|
||||
# Add wind info if space allows (using display width)
|
||||
# Be more aggressive about adding wind when current is Tonight
|
||||
wind_threshold = 115 if is_current_tonight else 120
|
||||
if period_wind_speed and period_wind_direction:
|
||||
test_str = weather + period_str
|
||||
if self._count_display_width(test_str) < wind_threshold:
|
||||
import re
|
||||
wind_match = re.search(r'(\d+)', period_wind_speed)
|
||||
if wind_match:
|
||||
wind_num = wind_match.group(1)
|
||||
wind_dir = self.abbreviate_wind_direction(period_wind_direction)
|
||||
if wind_dir:
|
||||
wind_info = f" {wind_dir}{wind_num}"
|
||||
if self._count_display_width(test_str + wind_info) <= 130:
|
||||
period_str += wind_info
|
||||
|
||||
# Only add if we have space (using display width, prioritize tomorrow)
|
||||
# Be more aggressive when current is Tonight - use up to 128 chars (leave 2 for alerts)
|
||||
max_chars = 128 if is_current_tonight else 130
|
||||
if self._count_display_width(weather + period_str) <= max_chars:
|
||||
weather += period_str
|
||||
|
||||
return weather, weather_json
|
||||
|
||||
@@ -509,6 +681,319 @@ class WxCommand(BaseCommand):
|
||||
self.logger.error(f"Error fetching NOAA weather: {e}")
|
||||
return self.ERROR_FETCHING_DATA, None
|
||||
|
||||
def format_tomorrow_forecast(self, forecast: list) -> str:
|
||||
"""Format a detailed forecast for tomorrow"""
|
||||
try:
|
||||
# Find tomorrow's periods
|
||||
# NOAA may use "Tomorrow", "Tomorrow Night" or day names like "Tuesday", "Tuesday Night"
|
||||
tomorrow_periods = []
|
||||
tomorrow_day_name = (datetime.now() + timedelta(days=1)).strftime('%A')
|
||||
|
||||
# First, try to find periods with "tomorrow" in the name
|
||||
for period in forecast:
|
||||
period_name = period.get('name', '').lower()
|
||||
if 'tomorrow' in period_name:
|
||||
tomorrow_periods.append(period)
|
||||
|
||||
# If not found, look for tomorrow's day name (e.g., "Tuesday", "Tuesday Night")
|
||||
if not tomorrow_periods:
|
||||
for period in forecast:
|
||||
period_name = period.get('name', '')
|
||||
period_name_lower = period_name.lower()
|
||||
# Check if it contains tomorrow's day name
|
||||
if tomorrow_day_name.lower() in period_name_lower:
|
||||
# Make sure it's not today
|
||||
today_day_name = datetime.now().strftime('%A')
|
||||
if today_day_name.lower() not in period_name_lower:
|
||||
tomorrow_periods.append(period)
|
||||
|
||||
# If still not found, find periods after "Tonight" (skip current day periods)
|
||||
# This handles cases where NOAA uses generic day names
|
||||
if not tomorrow_periods:
|
||||
found_tonight = False
|
||||
current_day_periods = 0
|
||||
for period in forecast:
|
||||
period_name = period.get('name', '').lower()
|
||||
# Count current day periods (Today, This Afternoon, Tonight, This Evening)
|
||||
if any(word in period_name for word in ['today', 'this afternoon', 'this evening', 'tonight']):
|
||||
current_day_periods += 1
|
||||
found_tonight = True
|
||||
continue
|
||||
if found_tonight:
|
||||
# This should be tomorrow's period
|
||||
tomorrow_periods.append(period)
|
||||
# Stop after collecting tomorrow's day and night periods (usually 2)
|
||||
if len(tomorrow_periods) >= 2:
|
||||
break
|
||||
|
||||
if not tomorrow_periods:
|
||||
return self.translate('commands.wx.tomorrow_not_available')
|
||||
|
||||
# Build detailed forecast for tomorrow
|
||||
parts = []
|
||||
for period in tomorrow_periods:
|
||||
period_name = self.abbreviate_noaa(period.get('name', 'Tomorrow'))
|
||||
temp = period.get('temperature', '')
|
||||
temp_unit = period.get('temperatureUnit', 'F')
|
||||
short_forecast = period.get('shortForecast', '')
|
||||
detailed_forecast = period.get('detailedForecast', '')
|
||||
wind_speed = period.get('windSpeed', '')
|
||||
wind_direction = period.get('windDirection', '')
|
||||
|
||||
if not temp or not short_forecast:
|
||||
continue
|
||||
|
||||
# Create period string
|
||||
emoji = self.get_weather_emoji(short_forecast)
|
||||
period_str = f"{period_name}: {emoji}{short_forecast} {temp}°{temp_unit}"
|
||||
|
||||
# Add wind info
|
||||
if wind_speed and wind_direction:
|
||||
import re
|
||||
wind_match = re.search(r'(\d+)', wind_speed)
|
||||
if wind_match:
|
||||
wind_num = wind_match.group(1)
|
||||
wind_dir = self.abbreviate_wind_direction(wind_direction)
|
||||
if wind_dir:
|
||||
period_str += f" {wind_dir}{wind_num}"
|
||||
|
||||
# Try to extract high/low
|
||||
high_low = self.extract_high_low(detailed_forecast)
|
||||
if high_low and '°' not in period_str.split()[-1]: # Avoid duplicate temp
|
||||
period_str = period_str.replace(f" {temp}°{temp_unit}", f" {high_low}")
|
||||
|
||||
parts.append(period_str)
|
||||
|
||||
if not parts:
|
||||
return self.translate('commands.wx.tomorrow_not_available')
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting tomorrow forecast: {e}")
|
||||
return self.translate('commands.wx.tomorrow_error')
|
||||
|
||||
def format_multiday_forecast(self, forecast: list, num_days: int = 7) -> str:
|
||||
"""Format a less detailed multi-day forecast summary"""
|
||||
try:
|
||||
# Group periods by day
|
||||
days = {}
|
||||
for period in forecast:
|
||||
period_name = period.get('name', '')
|
||||
period_name_lower = period_name.lower()
|
||||
|
||||
# Skip if it's a time period (Tonight, This Afternoon, etc.) unless it's the only period for that day
|
||||
# We want to focus on daily summaries
|
||||
if any(word in period_name_lower for word in ['tonight', 'afternoon', 'morning', 'evening']):
|
||||
# Only include if it's a named day (Monday, Tuesday, etc.)
|
||||
day_name = None
|
||||
for day in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']:
|
||||
if day in period_name_lower:
|
||||
day_name = day.capitalize()
|
||||
break
|
||||
|
||||
if not day_name:
|
||||
continue
|
||||
else:
|
||||
# Extract day name
|
||||
day_name = None
|
||||
for day in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']:
|
||||
if day in period_name_lower:
|
||||
day_name = day.capitalize()
|
||||
break
|
||||
|
||||
if not day_name:
|
||||
# Try to extract from "Tomorrow", "Today", etc.
|
||||
if 'tomorrow' in period_name_lower:
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
day_name = tomorrow.strftime('%A')
|
||||
elif 'today' in period_name_lower:
|
||||
day_name = datetime.now().strftime('%A')
|
||||
else:
|
||||
continue
|
||||
|
||||
# Get temperature (prefer high/low if available)
|
||||
temp = period.get('temperature', '')
|
||||
temp_unit = period.get('temperatureUnit', 'F')
|
||||
detailed_forecast = period.get('detailedForecast', '')
|
||||
high_low = self.extract_high_low(detailed_forecast)
|
||||
|
||||
if high_low:
|
||||
temp_str = high_low
|
||||
elif temp:
|
||||
temp_str = f"{temp}°"
|
||||
else:
|
||||
continue
|
||||
|
||||
# Get short forecast
|
||||
short_forecast = period.get('shortForecast', '')
|
||||
if not short_forecast:
|
||||
continue
|
||||
|
||||
# Store the best period for each day (prefer day periods over night)
|
||||
if day_name not in days:
|
||||
days[day_name] = {
|
||||
'temp': temp_str,
|
||||
'forecast': short_forecast,
|
||||
'is_day': 'night' not in period_name_lower and 'tonight' not in period_name_lower
|
||||
}
|
||||
else:
|
||||
# Prefer day periods, but update if we have better temp info
|
||||
if 'night' not in period_name_lower and 'tonight' not in period_name_lower:
|
||||
days[day_name] = {
|
||||
'temp': temp_str,
|
||||
'forecast': short_forecast,
|
||||
'is_day': True
|
||||
}
|
||||
elif not days[day_name]['is_day']:
|
||||
# Update night period if we don't have a day period
|
||||
days[day_name]['temp'] = temp_str
|
||||
days[day_name]['forecast'] = short_forecast
|
||||
|
||||
if not days:
|
||||
return self.translate('commands.wx.multiday_not_available', num_days=num_days)
|
||||
|
||||
# Format as compact summary
|
||||
parts = []
|
||||
day_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
# Get today's day name to start ordering
|
||||
today = datetime.now().strftime('%A')
|
||||
|
||||
# Reorder days starting from today
|
||||
if today in day_order:
|
||||
start_idx = day_order.index(today)
|
||||
ordered_days = day_order[start_idx:] + day_order[:start_idx]
|
||||
else:
|
||||
ordered_days = day_order
|
||||
|
||||
# Limit to requested number of days
|
||||
# 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'
|
||||
}
|
||||
|
||||
# Collect days up to num_days, starting from tomorrow (skip today)
|
||||
days_collected = 0
|
||||
for day in ordered_days[1:]: # Skip today, start from tomorrow
|
||||
if days_collected >= num_days:
|
||||
break
|
||||
if day in days:
|
||||
day_data = days[day]
|
||||
day_abbrev = day_abbrev_map.get(day, day[:2]) # Use 2-letter abbrev
|
||||
emoji = self.get_weather_emoji(day_data['forecast'])
|
||||
# Abbreviate forecast text
|
||||
forecast_short = self.abbreviate_noaa(day_data['forecast'])
|
||||
# Further shorten if needed to fit on one line (but be less aggressive)
|
||||
if len(forecast_short) > 25:
|
||||
forecast_short = forecast_short[:22] + "..."
|
||||
|
||||
parts.append(f"{day_abbrev}: {emoji}{forecast_short} {day_data['temp']}")
|
||||
days_collected += 1
|
||||
|
||||
if not parts:
|
||||
return self.translate('commands.wx.multiday_not_available', num_days=num_days)
|
||||
|
||||
# Join with newlines instead of pipes
|
||||
result = "\n".join(parts)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting {num_days}-day forecast: {e}")
|
||||
return self.translate('commands.wx.multiday_error', num_days=num_days)
|
||||
|
||||
def _count_display_width(self, text: str) -> int:
|
||||
"""Count display width of text, accounting for emojis which may take 2 display units"""
|
||||
import re
|
||||
# Count regular characters
|
||||
width = len(text)
|
||||
# Emojis typically take 2 display units in terminals/clients
|
||||
# Count emoji characters (basic emoji pattern)
|
||||
emoji_pattern = re.compile(
|
||||
"["
|
||||
"\U0001F600-\U0001F64F" # emoticons
|
||||
"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
"\U0001F1E0-\U0001F1FF" # flags
|
||||
"\U00002702-\U000027B0" # dingbats
|
||||
"\U000024C2-\U0001F251" # enclosed characters
|
||||
"]+",
|
||||
flags=re.UNICODE
|
||||
)
|
||||
emoji_matches = emoji_pattern.findall(text)
|
||||
# Each emoji sequence adds 1 extra width unit (since len() already counts it as 1)
|
||||
# So we add 1 for each emoji sequence to account for display width
|
||||
width += len(emoji_matches)
|
||||
return width
|
||||
|
||||
async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str):
|
||||
"""Send multi-day forecast response, splitting into multiple messages if needed"""
|
||||
import asyncio
|
||||
|
||||
lines = forecast_text.split('\n')
|
||||
|
||||
# Remove empty lines
|
||||
lines = [line.strip() for line in lines if line.strip()]
|
||||
|
||||
if not lines:
|
||||
return
|
||||
|
||||
# If single line and under 130 chars, send as-is
|
||||
if self._count_display_width(forecast_text) <= 130:
|
||||
await self.send_response(message, forecast_text)
|
||||
return
|
||||
|
||||
# Multi-line message - try to fit as many days as possible in one message
|
||||
# Only split when necessary (message would exceed 130 chars)
|
||||
current_message = ""
|
||||
message_count = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check if adding this line would exceed 130 characters (using display width)
|
||||
if current_message:
|
||||
test_message = current_message + "\n" + line
|
||||
else:
|
||||
test_message = line
|
||||
|
||||
# Only split if message would exceed 130 chars (using display width)
|
||||
if self._count_display_width(test_message) > 130:
|
||||
# Send current message and start new one
|
||||
if current_message:
|
||||
await self.send_response(message, current_message)
|
||||
message_count += 1
|
||||
# Wait between messages (same as other commands)
|
||||
if i < len(lines):
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
current_message = line
|
||||
else:
|
||||
# Single line is too long, send it anyway (will be truncated by bot)
|
||||
await self.send_response(message, line)
|
||||
message_count += 1
|
||||
if i < len(lines) - 1:
|
||||
await asyncio.sleep(2.0)
|
||||
current_message = ""
|
||||
else:
|
||||
# Add line to current message (fits within 130 chars)
|
||||
if current_message:
|
||||
current_message += "\n" + line
|
||||
else:
|
||||
current_message = line
|
||||
|
||||
# Send the last message if there's content
|
||||
if current_message:
|
||||
await self.send_response(message, current_message)
|
||||
|
||||
def get_weather_alerts_noaa(self, lat: float, lon: float) -> tuple:
|
||||
"""Get weather alerts from NOAA"""
|
||||
try:
|
||||
|
||||
+31
-20
@@ -31,6 +31,10 @@ from .command_manager import CommandManager
|
||||
from .channel_manager import ChannelManager
|
||||
from .scheduler import MessageScheduler
|
||||
from .repeater_manager import RepeaterManager
|
||||
from .db_manager import DBManager
|
||||
from .i18n import Translator
|
||||
from .solar_conditions import set_config
|
||||
from .web_viewer.integration import WebViewerIntegration
|
||||
|
||||
|
||||
class MeshCoreBot:
|
||||
@@ -55,7 +59,6 @@ class MeshCoreBot:
|
||||
db_path = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db')
|
||||
self.logger.info(f"Initializing database manager with database: {db_path}")
|
||||
try:
|
||||
from .db_manager import DBManager
|
||||
self.db_manager = DBManager(self, db_path)
|
||||
self.logger.info("Database manager initialized successfully")
|
||||
except Exception as e:
|
||||
@@ -71,7 +74,6 @@ class MeshCoreBot:
|
||||
|
||||
# Initialize web viewer integration (after database manager)
|
||||
try:
|
||||
from .web_viewer.integration import WebViewerIntegration
|
||||
self.web_viewer_integration = WebViewerIntegration(self)
|
||||
self.logger.info("Web viewer integration initialized")
|
||||
|
||||
@@ -89,6 +91,27 @@ class MeshCoreBot:
|
||||
self.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
|
||||
)
|
||||
self.tx_delay_ms = self.config.getint('Bot', 'tx_delay_ms', fallback=250)
|
||||
|
||||
# Initialize translator for localization BEFORE CommandManager
|
||||
# This ensures translated keywords are available when commands are loaded
|
||||
try:
|
||||
language = self.config.get('Localization', 'language', fallback='en')
|
||||
translation_path = self.config.get('Localization', 'translation_path', fallback='translations/')
|
||||
self.translator = Translator(language, translation_path)
|
||||
self.logger.info(f"Localization initialized: {language}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to initialize translator: {e}")
|
||||
# Create a dummy translator that just returns keys
|
||||
class DummyTranslator:
|
||||
def translate(self, key, **kwargs):
|
||||
return key
|
||||
def get_value(self, key):
|
||||
return None
|
||||
self.translator = DummyTranslator()
|
||||
|
||||
# Initialize solar conditions configuration
|
||||
set_config(self.config)
|
||||
|
||||
self.message_handler = MessageHandler(self)
|
||||
self.command_manager = CommandManager(self)
|
||||
self.channel_manager = ChannelManager(self)
|
||||
@@ -103,24 +126,12 @@ class MeshCoreBot:
|
||||
self.logger.error(f"Failed to initialize repeater manager: {e}")
|
||||
raise
|
||||
|
||||
# Initialize solar conditions configuration
|
||||
from .solar_conditions import set_config
|
||||
set_config(self.config)
|
||||
|
||||
# Initialize translator for localization
|
||||
try:
|
||||
from .i18n import Translator
|
||||
language = self.config.get('Localization', 'language', fallback='en')
|
||||
translation_path = self.config.get('Localization', 'translation_path', fallback='translations/')
|
||||
self.translator = Translator(language, translation_path)
|
||||
self.logger.info(f"Localization initialized: {language}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to initialize translator: {e}")
|
||||
# Create a dummy translator that just returns keys
|
||||
class DummyTranslator:
|
||||
def translate(self, key, **kwargs):
|
||||
return key
|
||||
self.translator = DummyTranslator()
|
||||
# Reload translated keywords for all commands now that translator is available
|
||||
# This ensures keywords are loaded even if translator wasn't ready during command init
|
||||
if hasattr(self, 'command_manager') and hasattr(self, 'translator'):
|
||||
for cmd_name, cmd_instance in self.command_manager.commands.items():
|
||||
if hasattr(cmd_instance, '_load_translated_keywords'):
|
||||
cmd_instance._load_translated_keywords()
|
||||
|
||||
# Advert tracking
|
||||
self.last_advert_time = None
|
||||
|
||||
+127
-75
@@ -2094,49 +2094,51 @@ class RepeaterManager:
|
||||
self.logger.error(f"❌ meshcore.commands.remove_contact() method not found on meshcore object")
|
||||
device_removal_successful = False
|
||||
else:
|
||||
# Use the MeshCore API: meshcore.commands.remove_contact(key)
|
||||
# Try with public_key first (most reliable identifier), then contact_key as fallback
|
||||
removal_attempted = False
|
||||
for key_to_try, key_name in [(public_key, 'public_key'), (contact_key, 'contact_key')]:
|
||||
if not key_to_try:
|
||||
continue
|
||||
# Use the MeshCore 2.2+ API: meshcore.commands.remove_contact(key)
|
||||
# remove_contact accepts: str (hex public key), bytes, or dict (contact object)
|
||||
removal_keys_to_try = []
|
||||
|
||||
# Add public_key if it's a valid 64-char hex string
|
||||
if public_key and len(public_key) == 64:
|
||||
removal_keys_to_try.append(('public_key', public_key))
|
||||
|
||||
# Add contact_key if it's different and valid
|
||||
if contact_key and contact_key != public_key and len(contact_key) == 64:
|
||||
removal_keys_to_try.append(('contact_key', contact_key))
|
||||
|
||||
# Try each key format
|
||||
for key_name, key_to_try in removal_keys_to_try:
|
||||
try:
|
||||
self.logger.debug(f"Calling meshcore.commands.remove_contact({key_name}='{key_to_try[:16]}...')")
|
||||
result = await asyncio.wait_for(
|
||||
self.bot.meshcore.commands.remove_contact(key_to_try),
|
||||
timeout=30.0
|
||||
)
|
||||
removal_attempted = True
|
||||
|
||||
# Check if result indicates success
|
||||
# Result could be: True, EventType.OK, or an event object with .type
|
||||
if result is True:
|
||||
# Check if removal was successful (meshcore 2.2+ returns Event object)
|
||||
if result.type == EventType.OK:
|
||||
device_removal_successful = True
|
||||
self.logger.info(f"✅ remove_contact returned True - removal successful")
|
||||
self.logger.info(f"✅ Successfully removed contact '{contact_name}' via meshcore 2.2+ API using {key_name}")
|
||||
break
|
||||
elif hasattr(result, 'type') and result.type == EventType.OK:
|
||||
device_removal_successful = True
|
||||
self.logger.info(f"✅ remove_contact returned EventType.OK - removal successful")
|
||||
break
|
||||
elif hasattr(result, 'type') and result.type == EventType.ERROR:
|
||||
elif result.type == EventType.ERROR:
|
||||
error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown'
|
||||
if error_code == 2:
|
||||
# Contact not found (already removed) - treat as success
|
||||
device_removal_successful = True
|
||||
self.logger.info(f"✅ Contact not found (already removed) - treating as success")
|
||||
self.logger.info(f"✅ Contact '{contact_name}' not found (already removed) - treating as success")
|
||||
break
|
||||
else:
|
||||
self.logger.debug(f"remove_contact returned error_code {error_code}, trying next key...")
|
||||
self.logger.debug(f"remove_contact({key_name}) returned error_code {error_code}, trying next key...")
|
||||
continue
|
||||
else:
|
||||
self.logger.debug(f"remove_contact returned unexpected result: {result}, trying next key...")
|
||||
self.logger.debug(f"remove_contact({key_name}) returned unexpected event type: {result.type}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"remove_contact({key_name}) failed: {type(e).__name__}: {e}, trying next key...")
|
||||
continue
|
||||
|
||||
if not removal_attempted:
|
||||
if not removal_keys_to_try:
|
||||
self.logger.error(f"❌ No valid key available for remove_contact")
|
||||
device_removal_successful = False
|
||||
elif not device_removal_successful:
|
||||
@@ -2149,27 +2151,47 @@ class RepeaterManager:
|
||||
|
||||
# Verify the contact was actually removed by checking contacts list
|
||||
if device_removal_successful:
|
||||
# First, manually remove from local cache since the API reported success
|
||||
# This prevents false negatives if the device hasn't updated its list yet
|
||||
if contact_key in self.bot.meshcore.contacts:
|
||||
del self.bot.meshcore.contacts[contact_key]
|
||||
self.logger.debug(f"Removed '{contact_name}' from local contacts cache")
|
||||
|
||||
# Wait a moment for the device to process the removal
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
# Refresh contacts from device to get latest state
|
||||
try:
|
||||
# Use the proper meshcore API to refresh contacts
|
||||
if hasattr(self.bot.meshcore, 'ensure_contacts'):
|
||||
await self.bot.meshcore.ensure_contacts(follow=True)
|
||||
elif hasattr(self.bot.meshcore.commands, 'get_contacts'):
|
||||
await self.bot.meshcore.commands.get_contacts(timeout=10.0)
|
||||
else:
|
||||
# Fallback: use CLI to refresh
|
||||
from meshcore_cli.meshcore_cli import next_cmd
|
||||
await asyncio.wait_for(
|
||||
next_cmd(self.bot.meshcore, ["contacts"]),
|
||||
timeout=15.0
|
||||
)
|
||||
self.logger.debug(f"Refreshed contacts from device")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not refresh contacts from device: {e}")
|
||||
|
||||
# Wait a bit more after refresh for events to process
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Refresh contacts to get latest state
|
||||
try:
|
||||
if hasattr(self.bot.meshcore, 'refresh_contacts'):
|
||||
await self.bot.meshcore.refresh_contacts()
|
||||
elif hasattr(self.bot.meshcore, 'update_contacts'):
|
||||
await self.bot.meshcore.update_contacts()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not refresh contacts: {e}")
|
||||
|
||||
# Check if contact still exists
|
||||
# Check if contact still exists after refresh
|
||||
contact_still_exists = any(
|
||||
contact_data.get('public_key', key) == public_key
|
||||
for key, contact_data in self.bot.meshcore.contacts.items()
|
||||
)
|
||||
|
||||
if contact_still_exists:
|
||||
self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list")
|
||||
device_removal_successful = False
|
||||
self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list after refresh")
|
||||
# Don't mark as failed - the API said it succeeded, device might just be slow
|
||||
# We'll trust the API response and mark as successful
|
||||
self.logger.info(f"⚠️ Trusting API success response despite contact still in list (device may be slow to update)")
|
||||
else:
|
||||
self.logger.debug(f"✅ Verified: contact '{contact_name}' successfully removed from device")
|
||||
|
||||
@@ -2400,38 +2422,61 @@ class RepeaterManager:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Direct removal failed: {e}")
|
||||
|
||||
# Method 2: Try using meshcore commands if available
|
||||
# Method 2: Try using meshcore commands if available (meshcore 2.2+ API)
|
||||
if not device_removal_successful and hasattr(self.bot.meshcore, 'commands'):
|
||||
try:
|
||||
self.logger.info(f"Method 2: Attempting removal via meshcore commands...")
|
||||
self.logger.info(f"Method 2: Attempting removal via meshcore 2.2+ API...")
|
||||
# Check if there's a remove_contact method
|
||||
if hasattr(self.bot.meshcore.commands, 'remove_contact'):
|
||||
# Try different parameter combinations
|
||||
try:
|
||||
# Try with contact_data
|
||||
result = await self.bot.meshcore.commands.remove_contact(contact_data)
|
||||
if result:
|
||||
self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (contact_data)")
|
||||
device_removal_successful = True
|
||||
except Exception as e1:
|
||||
self.logger.debug(f"remove_contact(contact_data) failed: {e1}")
|
||||
# remove_contact accepts: str (hex public key), bytes, or dict (contact object)
|
||||
removal_keys_to_try = []
|
||||
|
||||
# Add public_key if it's a valid 64-char hex string
|
||||
if public_key and len(public_key) == 64:
|
||||
removal_keys_to_try.append(('public_key', public_key))
|
||||
|
||||
# Add contact_data dict (meshcore 2.2+ supports dict with public_key field)
|
||||
if contact_data and isinstance(contact_data, dict):
|
||||
removal_keys_to_try.append(('contact_data', contact_data))
|
||||
|
||||
# Add contact_key if it's different and valid
|
||||
if contact_key and contact_key != public_key and len(contact_key) == 64:
|
||||
removal_keys_to_try.append(('contact_key', contact_key))
|
||||
|
||||
for key_name, key_to_try in removal_keys_to_try:
|
||||
try:
|
||||
# Try with public_key
|
||||
result = await self.bot.meshcore.commands.remove_contact(public_key)
|
||||
if result:
|
||||
self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (public_key)")
|
||||
self.logger.debug(f"Trying remove_contact with {key_name}...")
|
||||
result = await asyncio.wait_for(
|
||||
self.bot.meshcore.commands.remove_contact(key_to_try),
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
# Check if removal was successful (meshcore 2.2+ returns Event object)
|
||||
if result.type == EventType.OK:
|
||||
device_removal_successful = True
|
||||
except Exception as e2:
|
||||
self.logger.debug(f"remove_contact(public_key) failed: {e2}")
|
||||
try:
|
||||
# Try with contact_key
|
||||
result = await self.bot.meshcore.commands.remove_contact(contact_key)
|
||||
if result:
|
||||
self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (contact_key)")
|
||||
self.logger.info(f"✅ Successfully removed contact '{contact_name}' via meshcore 2.2+ API using {key_name}")
|
||||
break
|
||||
elif result.type == EventType.ERROR:
|
||||
error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown'
|
||||
# Error code 2 typically means "contact not found" - treat as success
|
||||
if error_code == 2:
|
||||
device_removal_successful = True
|
||||
except Exception as e3:
|
||||
self.logger.debug(f"remove_contact(contact_key) failed: {e3}")
|
||||
self.logger.warning(f"All meshcore commands remove_contact attempts failed")
|
||||
self.logger.info(f"✅ Contact '{contact_name}' not found (already removed) - treating as success")
|
||||
break
|
||||
else:
|
||||
self.logger.debug(f"remove_contact({key_name}) returned error_code {error_code}, trying next key...")
|
||||
continue
|
||||
else:
|
||||
self.logger.debug(f"remove_contact({key_name}) returned unexpected event type: {result.type}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"remove_contact({key_name}) failed: {type(e).__name__}: {e}, trying next key...")
|
||||
continue
|
||||
|
||||
if not device_removal_successful:
|
||||
self.logger.warning(f"All meshcore 2.2+ API remove_contact attempts failed for '{contact_name}'")
|
||||
else:
|
||||
self.logger.info("No remove_contact method found in meshcore commands")
|
||||
except Exception as e:
|
||||
@@ -2497,39 +2542,46 @@ class RepeaterManager:
|
||||
|
||||
# Verify removal and ensure persistence
|
||||
if device_removal_successful:
|
||||
import asyncio
|
||||
await asyncio.sleep(3) # Give device more time to process and save
|
||||
# First, manually remove from local cache since the API reported success
|
||||
# This prevents false negatives if the device hasn't updated its list yet
|
||||
if contact_key in self.bot.meshcore.contacts:
|
||||
del self.bot.meshcore.contacts[contact_key]
|
||||
self.logger.debug(f"Removed '{contact_name}' from local contacts cache")
|
||||
|
||||
# Try to force device to save changes
|
||||
await asyncio.sleep(2.0) # Give device time to process and save
|
||||
|
||||
# Try to refresh contacts from device to get latest state
|
||||
try:
|
||||
self.logger.info(f"Attempting to force device to save contact changes...")
|
||||
from meshcore_cli.meshcore_cli import next_cmd
|
||||
|
||||
# Try to refresh contacts from device
|
||||
try:
|
||||
self.logger.info("Refreshing contacts from device...")
|
||||
self.logger.debug("Refreshing contacts from device...")
|
||||
# Use the proper meshcore API to refresh contacts
|
||||
if hasattr(self.bot.meshcore, 'ensure_contacts'):
|
||||
await self.bot.meshcore.ensure_contacts(follow=True)
|
||||
elif hasattr(self.bot.meshcore.commands, 'get_contacts'):
|
||||
await self.bot.meshcore.commands.get_contacts(timeout=10.0)
|
||||
else:
|
||||
# Fallback: use CLI to refresh
|
||||
from meshcore_cli.meshcore_cli import next_cmd
|
||||
await asyncio.wait_for(
|
||||
next_cmd(self.bot.meshcore, ["contacts"]),
|
||||
timeout=15.0
|
||||
)
|
||||
self.logger.info("Contacts refreshed from device")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to refresh contacts: {e}")
|
||||
|
||||
self.logger.debug("Contacts refreshed from device")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to force device persistence: {e}")
|
||||
self.logger.debug(f"Could not refresh contacts from device: {e}")
|
||||
|
||||
# Wait a bit more after refresh
|
||||
await asyncio.sleep(1)
|
||||
# Wait a bit more after refresh for events to process
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Check if contact still exists in the bot's memory after refresh
|
||||
contact_still_exists = contact_key in self.bot.meshcore.contacts
|
||||
|
||||
if contact_still_exists:
|
||||
self.logger.warning(f"Contact '{contact_name}' still exists after removal and refresh - removal may have failed")
|
||||
device_removal_successful = False
|
||||
self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list after refresh")
|
||||
# Don't mark as failed - the API said it succeeded, device might just be slow
|
||||
# We'll trust the API response and mark as successful
|
||||
self.logger.info(f"⚠️ Trusting API success response despite contact still in list (device may be slow to update)")
|
||||
else:
|
||||
self.logger.info(f"Verified: Contact '{contact_name}' successfully removed from device")
|
||||
self.logger.info(f"✅ Verified: Contact '{contact_name}' successfully removed from device")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to remove contact '{contact_name}' from device: {e}")
|
||||
|
||||
+11
-3
@@ -198,7 +198,11 @@
|
||||
"no_location_zipcode": "Ort für PLZ {location} nicht gefunden",
|
||||
"no_location_city": "Stadt '{location}' in {state} nicht gefunden",
|
||||
"error": "Fehler beim Abruf der Wetterdaten: {error}",
|
||||
"alerts": "{count} Warnungen: {text}"
|
||||
"alerts": "{count} Warnungen: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Wetterinfo für beliebigen Ort abrufen (Verwendung: gwx Tokyo)",
|
||||
@@ -208,6 +212,10 @@
|
||||
"error_fetching_api": "Fehler beim Abruf der Wetterdaten von Open-Meteo",
|
||||
"no_location": "Ort '{location}' nicht gefunden",
|
||||
"error": "Fehler beim Abruf der Wetterdaten: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Heute",
|
||||
"tonight": "Heute Nacht",
|
||||
@@ -352,8 +360,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Repeater nach zweistelligem Präfix suchen (z.B. 'prefix 1A')",
|
||||
"help_api": "Repeater nach zweistelligem Präfix suchen{location_note}. Verwendung: 'prefix 1A' (zeigt aktuelle), 'prefix 1A all' (zeigt alle), 'prefix free' (listet verfügbare Präfixe) oder 'prefix refresh'.",
|
||||
"help_no_api": "Repeater nach zweistelligem Präfix in lokaler Datenbank suchen{location_note}. Verwendung: 'prefix 1A' (zeigt aktuelle), 'prefix 1A all' (zeigt alle), 'prefix free' (listet verfügbare Präfixe). Hinweis: API deaktiviert - nur lokale Daten.",
|
||||
"help_api": "Repeater nach Präfix suchen{location_note}. Verwendung: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Repeater nach Präfix suchen{location_note}. Verwendung: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Nur lokale DB)",
|
||||
"location_note": " (mit Stadtnamen)",
|
||||
"refresh_not_available": "❌ Aktualisierung nicht verfügbar - keine API-URL konfiguriert. Nur lokale Datenbank.",
|
||||
"cache_refreshed": "🔄 Repeater-Präfix-Cache aktualisiert!",
|
||||
|
||||
+11
-3
@@ -178,7 +178,11 @@
|
||||
"no_location_zipcode": "Could not find location for postcode {location}",
|
||||
"no_location_city": "Could not find city '{location}' in {state}",
|
||||
"error": "Error getting weather data: {error}",
|
||||
"alerts": "{count} alerts: {text}"
|
||||
"alerts": "{count} alerts: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Get weather information for any global location (usage: gwx Tokyo)",
|
||||
@@ -188,6 +192,10 @@
|
||||
"error_fetching_api": "Error fetching weather data from Open-Meteo",
|
||||
"no_location": "Could not find location '{location}'",
|
||||
"error": "Error getting weather data: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Today",
|
||||
"tonight": "Tonight",
|
||||
@@ -332,8 +340,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Look up repeaters by two-character prefix (e.g., 'prefix 1A')",
|
||||
"help_api": "Look up repeaters by two-character prefix{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes), or 'prefix refresh'.",
|
||||
"help_no_api": "Look up repeaters by two-character prefix using local database{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes). Note: API disabled - using local data only.",
|
||||
"help_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Local DB only)",
|
||||
"location_note": " (with city names)",
|
||||
"refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.",
|
||||
"cache_refreshed": "🔄 Repeater prefix cache refreshed!",
|
||||
|
||||
+11
-3
@@ -54,7 +54,11 @@
|
||||
"no_location_zipcode": "Could not find location for zipcode {location}",
|
||||
"no_location_city": "Could not find city '{location}' in {state}",
|
||||
"error": "Error getting weather data: {error}",
|
||||
"alerts": "{count} alerts: {text}"
|
||||
"alerts": "{count} alerts: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Get weather information for any global location (usage: gwx Tokyo)",
|
||||
@@ -64,6 +68,10 @@
|
||||
"error_fetching_api": "Error fetching weather data from Open-Meteo",
|
||||
"no_location": "Could not find location '{location}'",
|
||||
"error": "Error getting weather data: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Today",
|
||||
"tonight": "Tonight",
|
||||
@@ -208,8 +216,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Look up repeaters by two-character prefix (e.g., 'prefix 1A')",
|
||||
"help_api": "Look up repeaters by two-character prefix{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes), or 'prefix refresh'.",
|
||||
"help_no_api": "Look up repeaters by two-character prefix using local database{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes). Note: API disabled - using local data only.",
|
||||
"help_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Local DB only)",
|
||||
"location_note": " (with city names)",
|
||||
"refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.",
|
||||
"cache_refreshed": "🔄 Repeater prefix cache refreshed!",
|
||||
|
||||
+11
-3
@@ -54,7 +54,11 @@
|
||||
"no_location_zipcode": "No se pudo encontrar la ubicación para el código postal {location}",
|
||||
"no_location_city": "No se pudo encontrar la ciudad '{location}' en {state}",
|
||||
"error": "Error al obtener datos meteorológicos: {error}",
|
||||
"alerts": "{count} alertas: {text}"
|
||||
"alerts": "{count} alertas: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Obtener información meteorológica para cualquier ubicación global (uso: gwx Tokyo)",
|
||||
@@ -64,6 +68,10 @@
|
||||
"error_fetching_api": "Error al obtener datos meteorológicos de Open-Meteo",
|
||||
"no_location": "No se pudo encontrar la ubicación '{location}'",
|
||||
"error": "Error al obtener datos meteorológicos: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Hoy",
|
||||
"tonight": "Esta Noche",
|
||||
@@ -208,8 +216,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Buscar repetidores por prefijo de dos caracteres (ej., 'prefix 1A')",
|
||||
"help_api": "Buscar repetidores por prefijo de dos caracteres{location_note}. Uso: 'prefix 1A' (muestra recientes), 'prefix 1A all' (muestra todos), 'prefix free' (lista prefijos disponibles), o 'prefix refresh'.",
|
||||
"help_no_api": "Buscar repetidores por prefijo de dos caracteres usando base de datos local{location_note}. Uso: 'prefix 1A' (muestra recientes), 'prefix 1A all' (muestra todos), 'prefix free' (lista prefijos disponibles). Nota: API deshabilitado - usando solo datos locales.",
|
||||
"help_api": "Buscar repetidores por prefijo{location_note}. Uso: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Buscar repetidores por prefijo{location_note}. Uso: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Solo DB local)",
|
||||
"location_note": " (con nombres de ciudades)",
|
||||
"refresh_not_available": "❌ Actualización no disponible - no hay URL de API configurada. Usando solo base de datos local.",
|
||||
"cache_refreshed": "🔄 ¡Caché de prefijos de repetidores actualizado!",
|
||||
|
||||
+11
-3
@@ -250,7 +250,11 @@
|
||||
"no_location_zipcode": "Impossible de trouver l'emplacement pour le code postal {location}",
|
||||
"no_location_city": "Impossible de trouver la ville '{location}' dans {state}",
|
||||
"error": "Erreur lors de l'obtention des données météo : {error}",
|
||||
"alerts": "{count} alertes : {text}"
|
||||
"alerts": "{count} alertes : {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Obtenir les informations météo pour n'importe quel endroit dans le monde (utilisation : gwx Tokyo)",
|
||||
@@ -260,6 +264,10 @@
|
||||
"error_fetching_api": "Erreur lors de la récupération des données météo depuis Open-Meteo",
|
||||
"no_location": "Impossible de trouver l'emplacement '{location}'",
|
||||
"error": "Erreur lors de l'obtention des données météo : {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Aujourd'hui",
|
||||
"tonight": "Ce soir",
|
||||
@@ -404,8 +412,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Rechercher des répéteurs par préfixe de deux caractères (ex : 'prefix 1A')",
|
||||
"help_api": "Rechercher des répéteurs par préfixe de deux caractères{location_note}. Utilisation : 'prefix 1A' (affiche récents), 'prefix 1A all' (affiche tous), 'prefix free' (liste les préfixes disponibles), ou 'prefix refresh'.",
|
||||
"help_no_api": "Rechercher des répéteurs par préfixe de deux caractères en utilisant la base de données locale{location_note}. Utilisation : 'prefix 1A' (affiche récents), 'prefix 1A all' (affiche tous), 'prefix free' (liste les préfixes disponibles). Note : API désactivée - utilisation des données locales seulement.",
|
||||
"help_api": "Rechercher répéteurs par préfixe{location_note}. Utilisation : 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Rechercher répéteurs par préfixe{location_note}. Utilisation : 'prefix 1A', 'prefix 1A all', 'prefix free'. (DB locale seulement)",
|
||||
"location_note": " (avec noms de villes)",
|
||||
"refresh_not_available": "❌ Rafraîchissement non disponible - aucune URL d'API configurée. Utilisation de la base de données locale seulement.",
|
||||
"cache_refreshed": "🔄 Cache des préfixes de répéteur rafraîchi!",
|
||||
|
||||
+11
-3
@@ -178,7 +178,11 @@
|
||||
"no_location_zipcode": "Localisation introuvable pour code postal {location}",
|
||||
"no_location_city": "Ville '{location}' introuvable dans {state}",
|
||||
"error": "Erreur données météo: {error}",
|
||||
"alerts": "{count} alertes: {text}"
|
||||
"alerts": "{count} alertes: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Météo mondiale pour tout lieu (usage: gwx Tokyo)",
|
||||
@@ -188,6 +192,10 @@
|
||||
"error_fetching_api": "Erreur récupération données Open-Meteo",
|
||||
"no_location": "Lieu '{location}' introuvable",
|
||||
"error": "Erreur données météo: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Aujourd'hui",
|
||||
"tonight": "Ce soir",
|
||||
@@ -332,8 +340,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Recherche répéteurs par préfixe 2 caractères (ex: 'prefix 1A')",
|
||||
"help_api": "Recherche répéteurs par préfixe 2 caractères{location_note}. Usage: 'prefix 1A' (récents), 'prefix 1A all' (tous), 'prefix free' (préfixes dispo), ou 'prefix refresh'.",
|
||||
"help_no_api": "Recherche répéteurs par préfixe 2 caractères via base locale{location_note}. Usage: 'prefix 1A' (récents), 'prefix 1A all' (tous), 'prefix free' (préfixes dispo). Note: API désactivée - données locales uniquement.",
|
||||
"help_api": "Recherche répéteurs par préfixe{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Recherche répéteurs par préfixe{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (DB locale uniquement)",
|
||||
"location_note": " (avec noms villes)",
|
||||
"refresh_not_available": "❌ Actualisation indisponible - aucune URL API configurée. Base locale uniquement.",
|
||||
"cache_refreshed": "🔄 Cache préfixe répéteur actualisé!",
|
||||
|
||||
+11
-3
@@ -178,7 +178,11 @@
|
||||
"no_location_zipcode": "Locatie voor postcode {location} niet gevonden",
|
||||
"no_location_city": "Plaats '{location}' niet gevonden in {state}",
|
||||
"error": "Fout bij ophalen weerdata: {error}",
|
||||
"alerts": "{count} waarschuwingen: {text}"
|
||||
"alerts": "{count} waarschuwingen: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Weerinfo voor elke locatie wereldwijd (gebruik: gwx Tokyo)",
|
||||
@@ -188,6 +192,10 @@
|
||||
"error_fetching_api": "Fout bij ophalen weerdata van Open-Meteo",
|
||||
"no_location": "Locatie '{location}' niet gevonden",
|
||||
"error": "Fout bij ophalen weerdata: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Vandaag",
|
||||
"tonight": "Vanavond",
|
||||
@@ -332,8 +340,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Zoek repeaters op twee-karakter prefix (bijv. 'prefix 1A')",
|
||||
"help_api": "Zoek repeaters op twee-karakter prefix{location_note}. Gebruik: 'prefix 1A' (toont recente), 'prefix 1A all' (toont alle), 'prefix free' (lijst beschikbare prefixen), of 'prefix refresh'.",
|
||||
"help_no_api": "Zoek repeaters op twee-karakter prefix met lokale database{location_note}. Gebruik: 'prefix 1A' (toont recente), 'prefix 1A all' (toont alle), 'prefix free' (lijst beschikbare prefixen). Let op: API uitgeschakeld - alleen lokale data.",
|
||||
"help_api": "Zoek repeaters op prefix{location_note}. Gebruik: 'prefix 1A', 'prefix 1A all', 'prefix free'.",
|
||||
"help_no_api": "Zoek repeaters op prefix{location_note}. Gebruik: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Alleen lokale DB)",
|
||||
"location_note": " (met plaatsnamen)",
|
||||
"refresh_not_available": "❌ Refresh niet beschikbaar - geen API URL geconfigureerd. Alleen lokale database.",
|
||||
"cache_refreshed": "🔄 Repeater prefix cache ververst!",
|
||||
|
||||
+11
-3
@@ -175,7 +175,11 @@
|
||||
"no_location_zipcode": "Não achei localização pro CEP {location}",
|
||||
"no_location_city": "Não achei a cidade '{location}' em {state}",
|
||||
"error": "Erro ao pegar dados do tempo: {error}",
|
||||
"alerts": "{count} alertas: {text}"
|
||||
"alerts": "{count} alertas: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Ver informações do tempo pra qualquer lugar do mundo (uso: gwx Tokyo)",
|
||||
@@ -185,6 +189,10 @@
|
||||
"error_fetching_api": "Erro ao buscar dados do tempo no Open-Meteo",
|
||||
"no_location": "Não achei o local '{location}'",
|
||||
"error": "Erro ao pegar dados do tempo: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Hoje",
|
||||
"tonight": "Hoje à Noite",
|
||||
@@ -329,8 +337,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Procurar repetidores por prefixo de dois caracteres (ex: 'prefixo 1A')",
|
||||
"help_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free' ou 'prefixo refresh'.",
|
||||
"help_no_api": "Procurar repetidores por prefixo usando banco local{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free'. Obs: API desabilitada.",
|
||||
"help_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'.",
|
||||
"help_no_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'. (Apenas DB local)",
|
||||
"location_note": " (com nomes de cidades)",
|
||||
"refresh_not_available": "❌ Atualização indisponível - sem URL de API. Usando só banco local.",
|
||||
"cache_refreshed": "🔄 Cache de prefixo atualizado!",
|
||||
|
||||
+11
-3
@@ -174,7 +174,11 @@
|
||||
"no_location_zipcode": "Não foi possível encontrar localização para CEP {location}",
|
||||
"no_location_city": "Não foi possível encontrar cidade '{location}' em {state}",
|
||||
"error": "Erro ao obter dados meteorológicos: {error}",
|
||||
"alerts": "{count} alertas: {text}"
|
||||
"alerts": "{count} alertas: {text}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast"
|
||||
},
|
||||
"gwx": {
|
||||
"description": "Obter informações meteorológicas para qualquer localização global (uso: gwx Tokyo)",
|
||||
@@ -184,6 +188,10 @@
|
||||
"error_fetching_api": "Erro ao buscar dados meteorológicos do Open-Meteo",
|
||||
"no_location": "Não foi possível encontrar localização '{location}'",
|
||||
"error": "Erro ao obter dados meteorológicos: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Hoje",
|
||||
"tonight": "Esta Noite",
|
||||
@@ -328,8 +336,8 @@
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Pesquisar repetidores por prefixo de dois caracteres (ex: 'prefixo 1A')",
|
||||
"help_api": "Pesquisar repetidores por prefixo de dois caracteres{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free' ou 'prefixo refresh'.",
|
||||
"help_no_api": "Pesquisar repetidores por prefixo usando banco local{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free'. Nota: API desabilitada.",
|
||||
"help_api": "Pesquisar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'.",
|
||||
"help_no_api": "Pesquisar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'. (Apenas DB local)",
|
||||
"location_note": " (com nomes de cidades)",
|
||||
"refresh_not_available": "❌ Atualização indisponível - sem URL de API configurada. Usando banco de dados local apenas.",
|
||||
"cache_refreshed": "🔄 Cache de prefixo de repetidor atualizado!",
|
||||
|
||||
Reference in New Issue
Block a user