mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-22 14:01:14 +00:00
Merge pull request #13 from agessaman/dev-localization
Localization and some international functionality improvements.
This commit is contained in:
42 files changed
+11774
-531
No files matched your search
+22
-3
@@ -85,6 +85,21 @@ bot_latitude = 40.7128
|
||||
# Example: -74.0060 for New York City, -123.00 for Victoria BC
|
||||
bot_longitude = -74.0060
|
||||
|
||||
[Localization]
|
||||
# Language code for bot responses (en, es, es-MX, es-ES, fr, de, ja, etc.)
|
||||
# Default: en (English)
|
||||
# The bot will use translations from translations/{language}.json
|
||||
# Supports locale codes:
|
||||
# - Simple codes: en, es, fr, de, ja
|
||||
# - Locale codes: es-MX (Mexican Spanish), es-ES (Spain Spanish), fr-CA (Canadian French)
|
||||
# If locale-specific file not found, falls back to base language (e.g., es.json)
|
||||
# If translation file is missing or key not found, falls back to English
|
||||
language = en
|
||||
|
||||
# Path to translation files directory (relative to bot root)
|
||||
# Default: translations/
|
||||
translation_path = translations/
|
||||
|
||||
[Admin_ACL]
|
||||
# Admin Access Control List (ACL) for restricted commands
|
||||
# Only users with public keys listed here can execute admin commands
|
||||
@@ -171,14 +186,16 @@ advert_interval_hours = 0
|
||||
|
||||
[Keywords]
|
||||
# Keyword-response pairs (keyword = response format)
|
||||
# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path}
|
||||
# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance}
|
||||
# {sender}: Name/ID of message sender
|
||||
# {connection_info}: "Direct connection (0 hops)" or "Routed through X hops"
|
||||
# {snr}: Signal-to-noise ratio in dB
|
||||
# {timestamp}: Message timestamp in HH:MM:SS format
|
||||
# {path}: Message routing path (e.g., "01,5f (2 hops)")
|
||||
# {rssi}: Received Signal Strength Indicator in dBm
|
||||
test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}"
|
||||
# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)")
|
||||
# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing)
|
||||
test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}"
|
||||
ping = "Pong!"
|
||||
pong = "Ping!"
|
||||
help = "Bot Help: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | Use 'help <command>' for details"
|
||||
@@ -235,8 +252,10 @@ meshcore_log_level = INFO
|
||||
[Custom_Syntax]
|
||||
# Custom syntax patterns for special message formats
|
||||
# Format: pattern = "response_format"
|
||||
# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path}
|
||||
# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance}
|
||||
# {phrase}: The text after the trigger (for custom syntax patterns)
|
||||
# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)")
|
||||
# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing)
|
||||
#
|
||||
# Note: The "t" command is now handled by the test command as an alias
|
||||
# "t phrase" works the same as "test phrase" - both use the test response format
|
||||
|
||||
+40
-18
@@ -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():
|
||||
@@ -360,6 +369,9 @@ class CommandManager:
|
||||
except TypeError:
|
||||
# Fallback for commands that don't accept message parameter
|
||||
help_text = command.get_help_text()
|
||||
# Use translator if available
|
||||
if hasattr(self.bot, 'translator'):
|
||||
return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text)
|
||||
return f"Help {command_name}: {help_text}"
|
||||
|
||||
# If not found, search through all commands and their keywords
|
||||
@@ -372,6 +384,9 @@ class CommandManager:
|
||||
except TypeError:
|
||||
# Fallback for commands that don't accept message parameter
|
||||
help_text = cmd_instance.get_help_text()
|
||||
# Use translator if available
|
||||
if hasattr(self.bot, 'translator'):
|
||||
return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text)
|
||||
return f"Help {command_name}: {help_text}"
|
||||
|
||||
# If still not found, return unknown command message with helpful suggestion
|
||||
@@ -381,7 +396,10 @@ class CommandManager:
|
||||
if hasattr(cmd_instance, 'keywords'):
|
||||
available_commands.extend(cmd_instance.keywords)
|
||||
|
||||
return f"Unknown: {command_name}. Available: {', '.join(sorted(set(available_commands)))}. Try 'help' for command list."
|
||||
available_str = ', '.join(sorted(set(available_commands)))
|
||||
if hasattr(self.bot, 'translator'):
|
||||
return self.bot.translator.translate('commands.help.unknown', command=command_name, available=available_str)
|
||||
return f"Unknown: {command_name}. Available: {available_str}. Try 'help' for command list."
|
||||
|
||||
def get_general_help(self) -> str:
|
||||
"""Get general help text from config (LoRa-friendly compact format)"""
|
||||
@@ -481,9 +499,11 @@ class CommandManager:
|
||||
# Check if command can execute (cooldown, DM requirements, etc.)
|
||||
if not command.can_execute_now(message):
|
||||
if command.requires_dm and not message.is_dm:
|
||||
await self.send_response(message, f"Command '{command_name}' can only be used in DMs")
|
||||
error_msg = command.translate('errors.dm_only', command=command_name)
|
||||
await self.send_response(message, error_msg)
|
||||
elif command.requires_admin_access():
|
||||
await self.send_response(message, f"❌ Access denied: Command '{command_name}' requires admin privileges")
|
||||
error_msg = command.translate('errors.access_denied', command=command_name)
|
||||
await self.send_response(message, error_msg)
|
||||
elif hasattr(command, 'get_remaining_cooldown') and callable(command.get_remaining_cooldown):
|
||||
# Check if it's the per-user version (takes user_id parameter)
|
||||
import inspect
|
||||
@@ -494,7 +514,8 @@ class CommandManager:
|
||||
remaining = command.get_remaining_cooldown()
|
||||
|
||||
if remaining > 0:
|
||||
await self.send_response(message, f"Command '{command_name}' is on cooldown. Wait {remaining} seconds.")
|
||||
error_msg = command.translate('errors.cooldown', command=command_name, seconds=remaining)
|
||||
await self.send_response(message, error_msg)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -536,7 +557,8 @@ class CommandManager:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing command '{command_name}': {e}")
|
||||
# Send error message to user
|
||||
await self.send_response(message, f"Error executing {command_name}: {e}")
|
||||
error_msg = command.translate('errors.execution_error', command=command_name, error=str(e))
|
||||
await self.send_response(message, error_msg)
|
||||
|
||||
# Capture failed command for web viewer
|
||||
if (hasattr(self.bot, 'web_viewer_integration') and
|
||||
|
||||
@@ -21,7 +21,7 @@ class AdvertCommand(BaseCommand):
|
||||
category = "special"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.description
|
||||
return self.translate('commands.advert.description')
|
||||
|
||||
def can_execute(self, message: MeshMessage) -> bool:
|
||||
"""Check if advert command can be executed"""
|
||||
@@ -45,7 +45,7 @@ class AdvertCommand(BaseCommand):
|
||||
if hasattr(self.bot, 'last_advert_time') and self.bot.last_advert_time and (current_time - self.bot.last_advert_time) < 3600:
|
||||
remaining_time = 3600 - (current_time - self.bot.last_advert_time)
|
||||
remaining_minutes = int(remaining_time // 60)
|
||||
response = f"Advert cooldown active. Please wait {remaining_minutes} more minutes before requesting another advert."
|
||||
response = self.translate('commands.advert.cooldown_active', minutes=remaining_minutes)
|
||||
await self.send_response(message, response)
|
||||
return True
|
||||
|
||||
@@ -58,14 +58,14 @@ class AdvertCommand(BaseCommand):
|
||||
if hasattr(self.bot, 'last_advert_time'):
|
||||
self.bot.last_advert_time = current_time
|
||||
|
||||
response = "Flood advert sent successfully!"
|
||||
response = self.translate('commands.advert.success')
|
||||
self.logger.info("Flood advert sent successfully via DM command")
|
||||
|
||||
await self.send_response(message, response)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error sending flood advert: {e}"
|
||||
self.logger.error(error_msg)
|
||||
error_msg = self.translate('commands.advert.error', error=str(e))
|
||||
self.logger.error(f"Error sending flood advert: {e}")
|
||||
await self.send_response(message, error_msg)
|
||||
return False
|
||||
@@ -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
|
||||
@@ -22,8 +22,8 @@ class GlobalWxCommand(BaseCommand):
|
||||
category = "weather"
|
||||
cooldown_seconds = 5 # 5 second cooldown per user to prevent API abuse
|
||||
|
||||
# Error constants
|
||||
ERROR_FETCHING_DATA = "Error fetching weather data"
|
||||
# Error constants - will use translations instead
|
||||
ERROR_FETCHING_DATA = "ERROR_FETCHING_DATA" # Placeholder, will use translate()
|
||||
NO_ALERTS = "No weather alerts available"
|
||||
|
||||
def __init__(self, bot):
|
||||
@@ -60,7 +60,7 @@ class GlobalWxCommand(BaseCommand):
|
||||
self.db_manager = bot.db_manager
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Usage: gwx <location> - Get weather for any global location (city, country, or coordinates)"
|
||||
return self.translate('commands.gwx.help')
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Check if message starts with a weather keyword"""
|
||||
@@ -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, "Usage: gwx <location> - Example: gwx Tokyo or gwx Paris, France")
|
||||
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)
|
||||
|
||||
@@ -150,40 +181,54 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in global weather command: {e}")
|
||||
await self.send_response(message, f"Error getting weather data: {e}")
|
||||
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)
|
||||
if not result or result[0] is None or result[1] is None:
|
||||
return f"Could not find location '{location}'"
|
||||
return self.translate('commands.gwx.no_location', location=location)
|
||||
|
||||
lat, lon, address_info, geocode_result = result
|
||||
|
||||
# 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)
|
||||
if weather_text == self.ERROR_FETCHING_DATA:
|
||||
return "Error fetching weather data from Open-Meteo"
|
||||
# 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 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)
|
||||
# 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')
|
||||
|
||||
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}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting weather for {location}: {e}")
|
||||
return f"Error getting weather data: {e}"
|
||||
return self.translate('commands.gwx.error', error=str(e))
|
||||
|
||||
def geocode_location(self, location: str) -> tuple:
|
||||
"""Convert location string to lat/lon with address details"""
|
||||
@@ -340,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,
|
||||
@@ -356,14 +416,29 @@ 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:
|
||||
self.logger.warning(f"Error fetching weather from Open-Meteo: {response.status_code}")
|
||||
return self.ERROR_FETCHING_DATA
|
||||
return self.translate('commands.gwx.error_fetching')
|
||||
|
||||
data = response.json()
|
||||
|
||||
@@ -420,9 +495,9 @@ class GlobalWxCommand(BaseCommand):
|
||||
now = datetime.now()
|
||||
hour = now.hour
|
||||
if 6 <= hour < 18:
|
||||
period_name = "Today"
|
||||
period_name = self.translate('commands.gwx.periods.today')
|
||||
else:
|
||||
period_name = "Tonight"
|
||||
period_name = self.translate('commands.gwx.periods.tonight')
|
||||
|
||||
# Build current weather string
|
||||
weather = f"{period_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}"
|
||||
@@ -482,7 +557,7 @@ class GlobalWxCommand(BaseCommand):
|
||||
tomorrow_emoji = self._get_weather_emoji(tomorrow_code)
|
||||
|
||||
# Get tomorrow's period name
|
||||
tomorrow_period = "Tomorrow"
|
||||
tomorrow_period = self.translate('commands.gwx.periods.tomorrow')
|
||||
tomorrow_str = f" | {tomorrow_period}: {tomorrow_emoji}{tomorrow_high}{temp_symbol}/{tomorrow_low}{temp_symbol}"
|
||||
|
||||
# Only add if we have space (leave room for potential precipitation)
|
||||
@@ -501,7 +576,188 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching Open-Meteo weather: {e}")
|
||||
return self.ERROR_FETCHING_DATA
|
||||
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"""
|
||||
@@ -525,39 +781,46 @@ class GlobalWxCommand(BaseCommand):
|
||||
|
||||
def _get_weather_description(self, code: int) -> str:
|
||||
"""Convert WMO weather code to description"""
|
||||
# WMO Weather interpretation codes
|
||||
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"
|
||||
}
|
||||
# Try to get from translations first
|
||||
key = f"commands.gwx.weather_descriptions.{code}"
|
||||
description = self.translate(key)
|
||||
|
||||
return weather_codes.get(code, "Unknown")
|
||||
# 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"
|
||||
}
|
||||
return weather_codes.get(code, self.translate('commands.gwx.weather_descriptions.unknown'))
|
||||
|
||||
return description
|
||||
|
||||
def _get_weather_emoji(self, code: int) -> str:
|
||||
"""Convert WMO weather code to emoji"""
|
||||
@@ -603,25 +866,45 @@ class GlobalWxCommand(BaseCommand):
|
||||
if temp_match:
|
||||
temp = int(temp_match.group(1))
|
||||
if temp >= 95:
|
||||
warnings.append("⚠️ Extreme heat")
|
||||
warnings.append(self.translate('commands.gwx.warnings.extreme_heat'))
|
||||
elif temp <= 20:
|
||||
warnings.append("⚠️ Extreme cold")
|
||||
warnings.append(self.translate('commands.gwx.warnings.extreme_cold'))
|
||||
|
||||
# Check for severe weather indicators
|
||||
if "Heavy Rain" in weather_text or "Heavy Showers" in weather_text:
|
||||
warnings.append("⚠️ Heavy rain")
|
||||
# Note: We check for English strings here since weather descriptions might be in English
|
||||
# In a fully localized version, we'd need to check translated strings too
|
||||
heavy_rain_en = "Heavy Rain"
|
||||
heavy_showers_en = "Heavy Showers"
|
||||
thunderstorm_en = "Thunderstorm"
|
||||
t_storm_en = "T-Storm"
|
||||
heavy_snow_en = "Heavy Snow"
|
||||
snow_showers_en = "Snow Showers"
|
||||
|
||||
if "Thunderstorm" in weather_text or "T-Storm" in weather_text:
|
||||
warnings.append("⚠️ Thunderstorms")
|
||||
# Also get translated versions for checking
|
||||
heavy_rain_trans = self.translate('commands.gwx.weather_descriptions.65')
|
||||
heavy_showers_trans = self.translate('commands.gwx.weather_descriptions.82')
|
||||
thunderstorm_trans = self.translate('commands.gwx.weather_descriptions.95')
|
||||
t_storm_trans = self.translate('commands.gwx.weather_descriptions.96')
|
||||
heavy_snow_trans = self.translate('commands.gwx.weather_descriptions.75')
|
||||
snow_showers_trans = self.translate('commands.gwx.weather_descriptions.86')
|
||||
|
||||
if "Heavy Snow" in weather_text or "Snow Showers" in weather_text:
|
||||
warnings.append("⚠️ Heavy snow")
|
||||
if (heavy_rain_en in weather_text or heavy_showers_en in weather_text or
|
||||
heavy_rain_trans in weather_text or heavy_showers_trans in weather_text):
|
||||
warnings.append(self.translate('commands.gwx.warnings.heavy_rain'))
|
||||
|
||||
if (thunderstorm_en in weather_text or t_storm_en in weather_text or
|
||||
thunderstorm_trans in weather_text or t_storm_trans in weather_text):
|
||||
warnings.append(self.translate('commands.gwx.warnings.thunderstorms'))
|
||||
|
||||
if (heavy_snow_en in weather_text or snow_showers_en in weather_text or
|
||||
heavy_snow_trans in weather_text or snow_showers_trans in weather_text):
|
||||
warnings.append(self.translate('commands.gwx.warnings.heavy_snow'))
|
||||
|
||||
# Check for high winds
|
||||
wind_match = re.search(r'[NESW]{1,2}(\d+)', weather_text)
|
||||
if wind_match:
|
||||
wind_speed = int(wind_match.group(1))
|
||||
if wind_speed >= 30:
|
||||
warnings.append(f"⚠️ High winds ({wind_speed} mph)")
|
||||
warnings.append(self.translate('commands.gwx.warnings.high_winds', wind_speed=wind_speed))
|
||||
|
||||
return " | ".join(warnings) if warnings else None
|
||||
@@ -27,6 +27,39 @@ class BaseCommand(ABC):
|
||||
self.logger = bot.logger
|
||||
self._last_execution_time = 0
|
||||
|
||||
# Load translated keywords after initialization
|
||||
self._load_translated_keywords()
|
||||
|
||||
def translate(self, key: str, **kwargs) -> str:
|
||||
"""
|
||||
Translate a key using the bot's translator
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., 'commands.wx.usage')
|
||||
**kwargs: Formatting parameters for string.format()
|
||||
|
||||
Returns:
|
||||
Translated string, or key if translation not found
|
||||
"""
|
||||
if hasattr(self.bot, 'translator'):
|
||||
return self.bot.translator.translate(key, **kwargs)
|
||||
# Fallback if translator not available
|
||||
return key
|
||||
|
||||
def translate_get_value(self, key: str) -> Any:
|
||||
"""
|
||||
Get a raw value from translations (can be string, list, dict, etc.)
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors')
|
||||
|
||||
Returns:
|
||||
The value at the key path, or None if not found
|
||||
"""
|
||||
if hasattr(self.bot, 'translator'):
|
||||
return self.bot.translator.get_value(key)
|
||||
return None
|
||||
|
||||
def get_config_value(self, section: str, key: str, fallback=None, value_type: str = 'str'):
|
||||
"""
|
||||
Get config value with backward compatibility for section name changes.
|
||||
@@ -155,6 +188,34 @@ class BaseCommand(ABC):
|
||||
remaining = self.cooldown_seconds - elapsed
|
||||
return max(0, int(remaining))
|
||||
|
||||
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:
|
||||
# Get translated keywords for this command
|
||||
key = f"keywords.{self.name}"
|
||||
translated_keywords = self.bot.translator.get_value(key)
|
||||
|
||||
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:
|
||||
# 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:
|
||||
"""Check if this command matches the message content based on keywords"""
|
||||
if not self.keywords:
|
||||
|
||||
@@ -25,8 +25,8 @@ class CatfactCommand(BaseCommand):
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
|
||||
# Collection of cat facts
|
||||
self.cat_facts = [
|
||||
# Collection of cat facts - fallback if translations not available
|
||||
self.cat_facts_fallback = [
|
||||
"Cats have a third eyelid called a nictitating membrane that protects and moistens their eyes. 🐱",
|
||||
"A group of cats is called a 'clowder' or a 'glaring'. 🐈",
|
||||
"Cats can rotate their ears 180 degrees independently to pinpoint sounds. 👂",
|
||||
@@ -97,6 +97,13 @@ class CatfactCommand(BaseCommand):
|
||||
"A cat's average body temperature is 101.5°F (38.6°C) - higher than humans. 🌡️"
|
||||
]
|
||||
|
||||
def get_cat_facts(self) -> list:
|
||||
"""Get cat facts from translations or fallback to hardcoded list"""
|
||||
facts = self.translate_get_value('commands.catfact.facts')
|
||||
if facts and isinstance(facts, list) and len(facts) > 0:
|
||||
return facts
|
||||
return self.cat_facts_fallback
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
# Return empty string so it doesn't appear in help
|
||||
return ""
|
||||
@@ -146,8 +153,11 @@ class CatfactCommand(BaseCommand):
|
||||
# Record execution for this user
|
||||
self._record_execution(message.sender_id)
|
||||
|
||||
# Get cat facts from translations or fallback
|
||||
cat_facts = self.get_cat_facts()
|
||||
|
||||
# Get a random cat fact
|
||||
cat_fact = random.choice(self.cat_facts)
|
||||
cat_fact = random.choice(cat_facts)
|
||||
|
||||
# Send the cat fact
|
||||
await self.send_response(message, cat_fact)
|
||||
@@ -155,5 +165,5 @@ class CatfactCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in cat fact command: {e}")
|
||||
await self.send_response(message, "Meow? Something went wrong getting your cat fact! 🐱")
|
||||
await self.send_response(message, self.translate('commands.catfact.error'))
|
||||
return True
|
||||
@@ -20,7 +20,7 @@ class ChannelsCommand(BaseCommand):
|
||||
category = "basic"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Lists hashtag channels with sub-categories. Use 'channels' for general, 'channels list' for all categories, 'channels <category>' for specific categories, 'channels #channel' for specific channel info."
|
||||
return self.translate('commands.channels.help')
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Check if this command matches the message content based on keywords"""
|
||||
@@ -107,9 +107,9 @@ class ChannelsCommand(BaseCommand):
|
||||
|
||||
if not channels:
|
||||
if sub_command:
|
||||
await self.send_response(message, f"No channels configured for '{sub_command}'. Use 'channels' for general channels.")
|
||||
await self.send_response(message, self.translate('commands.channels.no_channels_for_category', category=sub_command))
|
||||
else:
|
||||
await self.send_response(message, "No channels configured. Contact admin to add channels.")
|
||||
await self.send_response(message, self.translate('commands.channels.no_channels_configured'))
|
||||
return True
|
||||
|
||||
# Build channel list (names only, no descriptions)
|
||||
@@ -127,7 +127,7 @@ class ChannelsCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in channels command: {e}")
|
||||
await self.send_response(message, f"Error retrieving channels: {e}")
|
||||
await self.send_response(message, self.translate('commands.channels.error_retrieving_channels', error=str(e)))
|
||||
return False
|
||||
|
||||
def _load_channels_from_config(self, sub_command: str = None) -> dict:
|
||||
@@ -169,13 +169,13 @@ class ChannelsCommand(BaseCommand):
|
||||
categories = self._get_all_categories()
|
||||
|
||||
if not categories:
|
||||
await self.send_response(message, "No channel categories configured.")
|
||||
await self.send_response(message, self.translate('commands.channels.no_categories_configured'))
|
||||
return
|
||||
|
||||
# Build category list
|
||||
category_list = []
|
||||
for category, count in categories.items():
|
||||
category_list.append(f"{category} ({count} channels)")
|
||||
category_list.append(self.translate('commands.channels.category_count', category=category, count=count))
|
||||
|
||||
# Split into multiple messages if needed
|
||||
messages = self._split_into_messages(category_list, "Available categories")
|
||||
@@ -185,7 +185,7 @@ class ChannelsCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error showing categories: {e}")
|
||||
await self.send_response(message, f"Error retrieving categories: {e}")
|
||||
await self.send_response(message, self.translate('commands.channels.error_retrieving_categories', error=str(e)))
|
||||
|
||||
def _get_all_categories(self) -> dict:
|
||||
"""Get all available channel categories and their channel counts"""
|
||||
@@ -252,7 +252,7 @@ class ChannelsCommand(BaseCommand):
|
||||
else:
|
||||
config_key = f"{found_category}.{found_channel[1:]}" # Remove #
|
||||
|
||||
description = self.bot.config.get('Channels_List', config_key, fallback='No description available')
|
||||
description = self.bot.config.get('Channels_List', config_key, fallback=self.translate('commands.channels.no_description_available'))
|
||||
|
||||
# Strip quotes if present
|
||||
if description.startswith('"') and description.endswith('"'):
|
||||
@@ -261,11 +261,11 @@ class ChannelsCommand(BaseCommand):
|
||||
response = f"{found_channel}: {description}"
|
||||
await self.send_response(message, response)
|
||||
else:
|
||||
await self.send_response(message, f"Channel {channel_name} not found. Use 'channels list' to see available channels.")
|
||||
await self.send_response(message, self.translate('commands.channels.channel_not_found', channel=channel_name))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error showing specific channel: {e}")
|
||||
await self.send_response(message, f"Error retrieving channel info: {e}")
|
||||
await self.send_response(message, self.translate('commands.channels.error_retrieving_channel_info', error=str(e)))
|
||||
|
||||
def _split_into_messages(self, channel_list: list, sub_command: str = None) -> list:
|
||||
"""Split channel list into multiple messages if they exceed 130 characters"""
|
||||
@@ -283,19 +283,20 @@ class ChannelsCommand(BaseCommand):
|
||||
|
||||
if current_message != expected_header:
|
||||
messages.append(current_message.rstrip(", "))
|
||||
current_message = "Channels (cont): "
|
||||
current_message = self.translate('commands.channels.headers.channels_cont')
|
||||
current_length = len(current_message)
|
||||
else:
|
||||
# If even the first channel is too long, just send it alone
|
||||
messages.append(f"{expected_header}{channel}")
|
||||
current_message = "Channels (cont): "
|
||||
current_message = self.translate('commands.channels.headers.channels_cont')
|
||||
current_length = len(current_message)
|
||||
continue
|
||||
|
||||
# Add channel to current message
|
||||
initial_header = self._get_header_for_subcommand(sub_command)
|
||||
continuation_header = self._get_continuation_header_for_subcommand(sub_command)
|
||||
if current_message == initial_header or current_message == continuation_header or current_message == "Channels (cont): ":
|
||||
channels_cont = self.translate('commands.channels.headers.channels_cont')
|
||||
if current_message == initial_header or current_message == continuation_header or current_message == channels_cont:
|
||||
current_message += channel
|
||||
else:
|
||||
current_message += f", {channel}"
|
||||
@@ -303,35 +304,36 @@ class ChannelsCommand(BaseCommand):
|
||||
|
||||
# Add the last message if it has content
|
||||
header = self._get_continuation_header_for_subcommand(sub_command)
|
||||
if current_message != header and current_message != "Channels (cont): ":
|
||||
channels_cont = self.translate('commands.channels.headers.channels_cont')
|
||||
if current_message != header and current_message != channels_cont:
|
||||
messages.append(current_message)
|
||||
|
||||
# If no messages were created, send a default message
|
||||
if not messages:
|
||||
if sub_command:
|
||||
messages.append(f"No {sub_command} channels configured")
|
||||
messages.append(self.translate('commands.channels.no_category_channels', category=sub_command))
|
||||
else:
|
||||
messages.append("No channels configured")
|
||||
messages.append(self.translate('commands.channels.no_channels_configured'))
|
||||
|
||||
return messages
|
||||
|
||||
def _get_header_for_subcommand(self, sub_command: str = None) -> str:
|
||||
"""Get the appropriate header for a sub-command"""
|
||||
if sub_command == "Available categories":
|
||||
return "Available Categories: "
|
||||
return self.translate('commands.channels.headers.available_categories')
|
||||
elif sub_command and sub_command != "general":
|
||||
return f"{sub_command.title()}: "
|
||||
return self.translate('commands.channels.headers.category', category=sub_command.title())
|
||||
else:
|
||||
return "Common channels: "
|
||||
return self.translate('commands.channels.headers.common_channels')
|
||||
|
||||
def _get_continuation_header_for_subcommand(self, sub_command: str = None) -> str:
|
||||
"""Get the appropriate header for continuation messages"""
|
||||
if sub_command == "Available categories":
|
||||
return "Available Categories: "
|
||||
return self.translate('commands.channels.headers.available_categories')
|
||||
elif sub_command and sub_command != "general":
|
||||
return f"{sub_command.title()} channels: "
|
||||
return self.translate('commands.channels.headers.category_channels', category=sub_command.title())
|
||||
else:
|
||||
return "Common channels: "
|
||||
return self.translate('commands.channels.headers.common_channels_cont')
|
||||
|
||||
async def _send_multiple_messages(self, message: MeshMessage, messages: list):
|
||||
"""Send multiple messages with delays between them"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class DiceCommand(BaseCommand):
|
||||
}
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Roll dice. Use 'dice' for d6, 'dice d20' for d20, 'dice 2d6' for 2d6, etc."
|
||||
return self.translate('commands.dice.help')
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Override to handle dice-specific matching"""
|
||||
@@ -114,12 +114,12 @@ class DiceCommand(BaseCommand):
|
||||
"""Format dice roll results into a readable string"""
|
||||
if count == 1:
|
||||
# Single die roll
|
||||
return f"🎲 d{sides}: {results[0]}"
|
||||
return self.translate('commands.dice.single_die', sides=sides, result=results[0])
|
||||
else:
|
||||
# Multiple dice
|
||||
total = sum(results)
|
||||
results_str = ", ".join(map(str, results))
|
||||
return f"🎲 {count}d{sides}: [{results_str}] = {total}"
|
||||
return self.translate('commands.dice.multiple_dice', count=count, sides=sides, results=results_str, total=total)
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the dice command"""
|
||||
@@ -141,7 +141,7 @@ class DiceCommand(BaseCommand):
|
||||
if sides is None:
|
||||
# Invalid dice specification
|
||||
available_dice = ", ".join(self.DICE_TYPES.keys())
|
||||
response = f"Invalid dice type. Available: {available_dice}"
|
||||
response = self.translate('commands.dice.invalid_dice_type', available=available_dice)
|
||||
return await self.send_response(message, response)
|
||||
|
||||
# Roll the dice
|
||||
|
||||
@@ -45,9 +45,18 @@ class HackerCommand(BaseCommand):
|
||||
"""Get a hilarious error message for the given command"""
|
||||
command_lower = command.lower()
|
||||
|
||||
# Try to get errors from translations, fallback to hardcoded if not available
|
||||
def get_random_error(error_key: str, fallback_list: list) -> str:
|
||||
"""Get a random error from translations or fallback list"""
|
||||
errors = self.translate_get_value(error_key)
|
||||
if isinstance(errors, list) and len(errors) > 0:
|
||||
return random.choice(errors)
|
||||
# Fallback to hardcoded list if translation not available
|
||||
return random.choice(fallback_list)
|
||||
|
||||
# sudo command errors
|
||||
if command_lower.startswith('sudo'):
|
||||
sudo_errors = [
|
||||
fallback = [
|
||||
"🚨 ACCESS DENIED: Dr. Evil's mainframe has detected unauthorized privilege escalation attempt!",
|
||||
"💀 ERROR: Sudo permissions revoked by the Dark Overlord. Try again in 1000 years.",
|
||||
"⚡ WARNING: Attempting to access root privileges on the Death Star's computer system. Self-destruct sequence initiated.",
|
||||
@@ -59,11 +68,11 @@ class HackerCommand(BaseCommand):
|
||||
"💻 HACKER DENIED: The supervillain's antivirus has quarantined your privilege escalation.",
|
||||
"🎯 TARGET LOCKED: The evil corporation's security system has marked you as a threat."
|
||||
]
|
||||
return random.choice(sudo_errors)
|
||||
return get_random_error('commands.hacker.sudo_errors', fallback)
|
||||
|
||||
# ps aux command errors
|
||||
elif command_lower.startswith('ps aux'):
|
||||
ps_errors = [
|
||||
fallback = [
|
||||
"🔍 SCANNING... ERROR: Process list corrupted by the Borg Collective. Resistance is futile.",
|
||||
"📊 SYSTEM STATUS: All processes have been assimilated by the Cybermen. Exterminate!",
|
||||
"⚙️ PROCESS MONITOR: The Death Star's reactor core is offline. No processes found.",
|
||||
@@ -75,11 +84,11 @@ class HackerCommand(BaseCommand):
|
||||
"🌐 NETWORK ERROR: All processes have been disconnected from the Matrix.",
|
||||
"⚡ POWER SURGE: The supervillain's server farm has fried all running processes."
|
||||
]
|
||||
return random.choice(ps_errors)
|
||||
return get_random_error('commands.hacker.ps_errors', fallback)
|
||||
|
||||
# grep command errors
|
||||
elif command_lower.startswith('grep'):
|
||||
grep_errors = [
|
||||
fallback = [
|
||||
"🔍 SEARCH FAILED: The One Ring has corrupted the search index. My precious...",
|
||||
"📝 PATTERN NOT FOUND: The search database has been deleted by the evil AI.",
|
||||
"🎯 MISS: Your search pattern has been shot down by Imperial TIE fighters.",
|
||||
@@ -91,11 +100,11 @@ class HackerCommand(BaseCommand):
|
||||
"🌐 NETWORK TIMEOUT: The search request got lost in cyberspace.",
|
||||
"⚡ SEARCH FAILED: The pattern matching algorithm has been fried by a power surge."
|
||||
]
|
||||
return random.choice(grep_errors)
|
||||
return get_random_error('commands.hacker.grep_errors', fallback)
|
||||
|
||||
# ls -l and ls -la command errors
|
||||
elif command_lower.startswith('ls -l') or command_lower.startswith('ls -la'):
|
||||
ls_errors = [
|
||||
fallback = [
|
||||
"📁 DIRECTORY SCAN: The file system has been encrypted by ransomware from the Dark Web.",
|
||||
"🗂️ FILE LISTING: All files have been hidden by the Invisible Man.",
|
||||
"💻 HARD DRIVE CRASHED: The supervillain's storage has been destroyed by a virus.",
|
||||
@@ -107,11 +116,11 @@ class HackerCommand(BaseCommand):
|
||||
"🌐 CLOUD STORAGE DOWN: The files are stuck in the Matrix's cloud.",
|
||||
"⚡ STORAGE FRIED: The hard drive has been zapped by a power surge."
|
||||
]
|
||||
return random.choice(ls_errors)
|
||||
return get_random_error('commands.hacker.ls_errors', fallback)
|
||||
|
||||
# echo $PATH command errors
|
||||
elif command_lower.startswith('echo $path'):
|
||||
echo_path_errors = [
|
||||
fallback = [
|
||||
"🛤️ PATH ERROR: The Yellow Brick Road has been destroyed by a tornado.",
|
||||
"🗺️ NAVIGATION FAILED: The GPS coordinates have been scrambled by the Matrix.",
|
||||
"💻 ENVIRONMENT VARIABLE CORRUPTED: The PATH has been hacked by malware.",
|
||||
@@ -123,11 +132,11 @@ class HackerCommand(BaseCommand):
|
||||
"🌐 NETWORK PATH DOWN: The directory paths are stuck in the Matrix's network.",
|
||||
"⚡ PATH FRIED: The system paths have been zapped by a power surge."
|
||||
]
|
||||
return random.choice(echo_path_errors)
|
||||
return get_random_error('commands.hacker.echo_path_errors', fallback)
|
||||
|
||||
# Generic hacker error for other commands
|
||||
else:
|
||||
generic_errors = [
|
||||
fallback = [
|
||||
"💻 MAINFRAME ERROR: The supervillain's computer is having a bad day.",
|
||||
"🤖 SYSTEM MALFUNCTION: The evil AI has gone on strike.",
|
||||
"⚡ POWER SURGE: The Death Star's power core is unstable.",
|
||||
@@ -139,7 +148,7 @@ class HackerCommand(BaseCommand):
|
||||
"👻 HAUNTED: The system is possessed by digital ghosts.",
|
||||
"🎮 GAME CRASH: The mainframe has encountered a fatal error and needs to restart."
|
||||
]
|
||||
return random.choice(generic_errors)
|
||||
return get_random_error('commands.hacker.generic_errors', fallback)
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Override to check for command matches (exact for some, prefix for others)"""
|
||||
|
||||
@@ -21,8 +21,13 @@ class HelloCommand(BaseCommand):
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
|
||||
# Fallback arrays if translations not available
|
||||
self._init_fallback_arrays()
|
||||
|
||||
def _init_fallback_arrays(self):
|
||||
"""Initialize fallback arrays for when translations are not available"""
|
||||
# Time-neutral greeting openings
|
||||
self.greeting_openings = [
|
||||
self.greeting_openings_fallback = [
|
||||
"Hello", "Greetings", "Salutations", "Hi", "Hey", "Howdy", "Yo", "Sup",
|
||||
"What's up", "Good day", "Well met", "Hail", "Ahoy", "Bonjour", "Hola",
|
||||
"Ciao", "Namaste", "Aloha", "Shalom", "Konnichiwa", "Guten tag", "G'day",
|
||||
@@ -32,26 +37,26 @@ class HelloCommand(BaseCommand):
|
||||
]
|
||||
|
||||
# Time-based greeting openings
|
||||
self.morning_greetings = [
|
||||
self.morning_greetings_fallback = [
|
||||
"Good morning", "Top o' the morning", "Buenos dias", "Bonjour",
|
||||
"Guten morgen", "Buongiorno", "Bom dia", "Dobro jutro", "Dobroye utro",
|
||||
"Selamat pagi", "Ohayou gozaimasu", "Sabah al-khair", "Boker tov"
|
||||
]
|
||||
|
||||
self.afternoon_greetings = [
|
||||
self.afternoon_greetings_fallback = [
|
||||
"Good afternoon", "Buenas tardes", "Boa tarde", "Dobro dan",
|
||||
"Dobryy den", "Selamat siang", "Konnichiwa", "Ahlan bi-nahar",
|
||||
"Tzoharaim tovim"
|
||||
]
|
||||
|
||||
self.evening_greetings = [
|
||||
self.evening_greetings_fallback = [
|
||||
"Good evening", "Buenas noches", "Boa noite", "Dobro veče",
|
||||
"Dobryy vecher", "Selamat malam", "Konbanwa", "Ahlan bi-layl",
|
||||
"Erev tov"
|
||||
]
|
||||
|
||||
# Randomized human descriptors
|
||||
self.human_descriptors = [
|
||||
self.human_descriptors_fallback = [
|
||||
# Classic robot references
|
||||
"human", "carbon-based lifeform", "organic entity", "biological unit",
|
||||
"flesh creature", "meat-based organism", "carbon unit", "organic being",
|
||||
@@ -80,7 +85,7 @@ class HelloCommand(BaseCommand):
|
||||
]
|
||||
|
||||
# Emoji greeting responses
|
||||
self.emoji_responses = {
|
||||
self.emoji_responses_fallback = {
|
||||
'🖖': [
|
||||
"🖖 Live long and prosper!",
|
||||
"🖖 Fascinating... a human has initiated contact.",
|
||||
@@ -166,8 +171,50 @@ class HelloCommand(BaseCommand):
|
||||
]
|
||||
}
|
||||
|
||||
def get_greeting_openings(self) -> list:
|
||||
"""Get greeting openings from translations or fallback"""
|
||||
openings = self.translate_get_value('commands.hello.greeting_openings')
|
||||
if openings and isinstance(openings, list) and len(openings) > 0:
|
||||
return openings
|
||||
return self.greeting_openings_fallback
|
||||
|
||||
def get_morning_greetings(self) -> list:
|
||||
"""Get morning greetings from translations or fallback"""
|
||||
greetings = self.translate_get_value('commands.hello.morning_greetings')
|
||||
if greetings and isinstance(greetings, list) and len(greetings) > 0:
|
||||
return greetings
|
||||
return self.morning_greetings_fallback
|
||||
|
||||
def get_afternoon_greetings(self) -> list:
|
||||
"""Get afternoon greetings from translations or fallback"""
|
||||
greetings = self.translate_get_value('commands.hello.afternoon_greetings')
|
||||
if greetings and isinstance(greetings, list) and len(greetings) > 0:
|
||||
return greetings
|
||||
return self.afternoon_greetings_fallback
|
||||
|
||||
def get_evening_greetings(self) -> list:
|
||||
"""Get evening greetings from translations or fallback"""
|
||||
greetings = self.translate_get_value('commands.hello.evening_greetings')
|
||||
if greetings and isinstance(greetings, list) and len(greetings) > 0:
|
||||
return greetings
|
||||
return self.evening_greetings_fallback
|
||||
|
||||
def get_human_descriptors(self) -> list:
|
||||
"""Get human descriptors from translations or fallback"""
|
||||
descriptors = self.translate_get_value('commands.hello.human_descriptors')
|
||||
if descriptors and isinstance(descriptors, list) and len(descriptors) > 0:
|
||||
return descriptors
|
||||
return self.human_descriptors_fallback
|
||||
|
||||
def get_emoji_responses(self) -> dict:
|
||||
"""Get emoji responses from translations or fallback"""
|
||||
responses = self.translate_get_value('commands.hello.emoji_responses')
|
||||
if responses and isinstance(responses, dict) and len(responses) > 0:
|
||||
return responses
|
||||
return self.emoji_responses_fallback
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.description
|
||||
return self.translate('commands.hello.help')
|
||||
|
||||
def matches_custom_syntax(self, message: MeshMessage) -> bool:
|
||||
"""Check if message contains only defined emojis"""
|
||||
@@ -184,7 +231,8 @@ class HelloCommand(BaseCommand):
|
||||
else:
|
||||
# Get random robot greeting
|
||||
random_greeting = self.get_random_greeting()
|
||||
response = f"{random_greeting} I'm {bot_name}."
|
||||
response_format = self.translate('commands.hello.response_format')
|
||||
response = f"{random_greeting} {response_format}".format(bot_name=bot_name)
|
||||
|
||||
return await self.send_response(message, response)
|
||||
|
||||
@@ -211,18 +259,25 @@ class HelloCommand(BaseCommand):
|
||||
# Get current hour to determine time of day
|
||||
current_hour = current_time.hour
|
||||
|
||||
# Get greeting arrays from translations or fallback
|
||||
greeting_openings = self.get_greeting_openings()
|
||||
morning_greetings = self.get_morning_greetings()
|
||||
afternoon_greetings = self.get_afternoon_greetings()
|
||||
evening_greetings = self.get_evening_greetings()
|
||||
human_descriptors = self.get_human_descriptors()
|
||||
|
||||
# Choose appropriate greeting based on time of day
|
||||
if 5 <= current_hour < 12: # Morning (5 AM - 12 PM)
|
||||
greeting_pool = self.morning_greetings + self.greeting_openings
|
||||
greeting_pool = morning_greetings + greeting_openings
|
||||
elif 12 <= current_hour < 17: # Afternoon (12 PM - 5 PM)
|
||||
greeting_pool = self.afternoon_greetings + self.greeting_openings
|
||||
greeting_pool = afternoon_greetings + greeting_openings
|
||||
elif 17 <= current_hour < 22: # Evening (5 PM - 10 PM)
|
||||
greeting_pool = self.evening_greetings + self.greeting_openings
|
||||
greeting_pool = evening_greetings + greeting_openings
|
||||
else: # Night/Late night (10 PM - 5 AM)
|
||||
greeting_pool = self.evening_greetings + self.greeting_openings
|
||||
greeting_pool = evening_greetings + greeting_openings
|
||||
|
||||
opening = random.choice(greeting_pool)
|
||||
descriptor = random.choice(self.human_descriptors)
|
||||
descriptor = random.choice(human_descriptors)
|
||||
|
||||
# Add some variety in punctuation and formatting
|
||||
punctuation_options = ["!", ".", "!", "!", "!"] # Favor exclamation marks
|
||||
@@ -253,14 +308,18 @@ class HelloCommand(BaseCommand):
|
||||
"""Get appropriate response for emoji-only message"""
|
||||
import random
|
||||
|
||||
# Get emoji responses from translations or fallback
|
||||
emoji_responses = self.get_emoji_responses()
|
||||
response_format = self.translate('commands.hello.response_format')
|
||||
|
||||
# Extract the first emoji from the message
|
||||
first_emoji = text.strip().split()[0] if text.strip() else ""
|
||||
|
||||
# Check if this emoji has special responses
|
||||
if first_emoji in self.emoji_responses:
|
||||
response = random.choice(self.emoji_responses[first_emoji])
|
||||
return f"{response} I'm {bot_name}."
|
||||
if first_emoji in emoji_responses:
|
||||
response = random.choice(emoji_responses[first_emoji])
|
||||
return f"{response} {response_format}".format(bot_name=bot_name)
|
||||
else:
|
||||
# Use random greeting generator for general emojis
|
||||
random_greeting = self.get_random_greeting()
|
||||
return f"{random_greeting} I'm {bot_name}."
|
||||
return f"{random_greeting} {response_format}".format(bot_name=bot_name)
|
||||
@@ -18,7 +18,7 @@ class HelpCommand(BaseCommand):
|
||||
category = "basic"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.description
|
||||
return self.translate('commands.help.description')
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the help command"""
|
||||
@@ -53,27 +53,18 @@ class HelpCommand(BaseCommand):
|
||||
# Fallback for commands that don't accept message parameter
|
||||
help_text = command.get_help_text()
|
||||
else:
|
||||
help_text = "No help available"
|
||||
return f"**Help for '{command_name}':**\n{help_text}"
|
||||
help_text = self.translate('commands.help.no_help')
|
||||
return self.translate('commands.help.specific', command=command_name, help_text=help_text)
|
||||
else:
|
||||
return f"**Unknown command: '{command_name}'**\n\nAvailable commands:\n" + self.get_available_commands_list()
|
||||
available = self.get_available_commands_list()
|
||||
return self.translate('commands.help.unknown', command=command_name, available=available)
|
||||
|
||||
def get_general_help(self) -> str:
|
||||
"""Get general help text"""
|
||||
help_text = "**MeshCore Bot Help**\n\n"
|
||||
help_text += "**Available Commands:**\n"
|
||||
help_text += self.get_available_commands_list()
|
||||
help_text += "\n**Usage Examples:**\n"
|
||||
help_text += "• `help @` - Get help for @{string} syntax\n"
|
||||
help_text += "• `help t` - Get help for t {string} syntax\n"
|
||||
help_text += "• `help advert` - Get help for advert command\n"
|
||||
help_text += "• `help test` - Get help for test command\n"
|
||||
help_text += "• `help ping` - Get help for ping command\n"
|
||||
help_text += "\n**Custom Syntax:**\n"
|
||||
help_text += "• `t phrase` - Acknowledgment with phrase (channels & DMs)\n"
|
||||
help_text += "• `@{string}` - Acknowledgment with string (DMs only)\n"
|
||||
help_text += "• `advert` - Send flood advert (DMs only, 1-hour cooldown)\n"
|
||||
|
||||
commands_list = self.get_available_commands_list()
|
||||
help_text = self.translate('commands.help.general', commands_list=commands_list)
|
||||
help_text += self.translate('commands.help.usage_examples')
|
||||
help_text += self.translate('commands.help.custom_syntax')
|
||||
return help_text
|
||||
|
||||
def get_available_commands_list(self) -> str:
|
||||
|
||||
@@ -22,13 +22,13 @@ class HfcondCommand(BaseCommand):
|
||||
hf_info = hf_band_conditions()
|
||||
|
||||
# Send response using unified method
|
||||
response = f"📡 HF Band Conditions:\n{hf_info}"
|
||||
response = self.translate('commands.hfcond.header', info=hf_info)
|
||||
return await self.send_response(message, response)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error getting HF conditions: {e}"
|
||||
error_msg = self.translate('commands.hfcond.error', error=str(e))
|
||||
return await self.send_response(message, error_msg)
|
||||
|
||||
def get_help_text(self):
|
||||
"""Get help text for this command"""
|
||||
return "Get HF band conditions for ham radio"
|
||||
return self.translate('commands.hfcond.help')
|
||||
@@ -34,10 +34,40 @@ class MoonCommand(BaseCommand):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error getting moon info: {e}"
|
||||
error_msg = self.translate('commands.moon.error', error=str(e))
|
||||
await self.send_response(message, error_msg)
|
||||
return False
|
||||
|
||||
def _translate_phase_name(self, phase_name: str) -> str:
|
||||
"""Translate English phase name to localized version"""
|
||||
# Map English phase names (with or without emoji) to translation keys
|
||||
phase_mapping = {
|
||||
'New Moon': 'new_moon',
|
||||
'Waxing Crescent': 'waxing_crescent',
|
||||
'First Quarter': 'first_quarter',
|
||||
'Waxing Gibbous': 'waxing_gibbous',
|
||||
'Full Moon': 'full_moon',
|
||||
'Waning Gibbous': 'waning_gibbous',
|
||||
'Last Quarter': 'last_quarter',
|
||||
'Waning Crescent': 'waning_crescent'
|
||||
}
|
||||
|
||||
# Remove emoji if present (they're in the translation)
|
||||
phase_clean = phase_name
|
||||
for emoji in ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']:
|
||||
phase_clean = phase_clean.replace(emoji, '').strip()
|
||||
|
||||
# Find matching translation key
|
||||
for english_name, translation_key in phase_mapping.items():
|
||||
if english_name in phase_clean:
|
||||
translated = self.translate(f'commands.moon.phases.{translation_key}')
|
||||
# If translation found (not just the key), return it
|
||||
if translated != f'commands.moon.phases.{translation_key}':
|
||||
return translated
|
||||
|
||||
# Fallback: return original if no translation found
|
||||
return phase_name
|
||||
|
||||
def _format_moon_response(self, moon_info: str) -> str:
|
||||
"""Format moon information to be more compact and readable"""
|
||||
try:
|
||||
@@ -57,17 +87,17 @@ class MoonCommand(BaseCommand):
|
||||
set_info = moon_data['Set'] # e.g., "Fri 05 03:43AM"
|
||||
|
||||
# Extract phase and illumination
|
||||
phase_info = moon_data.get('Phase', 'Unknown')
|
||||
phase_info = moon_data.get('Phase', self.translate('commands.moon.unknown_phase'))
|
||||
if '@:' in phase_info:
|
||||
phase, illum = phase_info.split('@:')
|
||||
phase = phase.strip()
|
||||
illum = illum.strip()
|
||||
# Translate the phase name
|
||||
phase = self._translate_phase_name(phase)
|
||||
else:
|
||||
phase = phase_info
|
||||
illum = 'N/A'
|
||||
|
||||
# Create compact response with essential details
|
||||
response = f"🌙 {phase} {illum}\nRise:{rise_info} Set:{set_info}"
|
||||
phase = self._translate_phase_name(phase)
|
||||
illum = self.translate('commands.moon.unknown_illum')
|
||||
|
||||
# Add next full and new moon dates (compact format)
|
||||
if 'FullMoon' in moon_data and 'NewMoon' in moon_data:
|
||||
@@ -82,16 +112,23 @@ class MoonCommand(BaseCommand):
|
||||
# Format: "Sep 07 11:08AM" and "Sep 21 12:54PM"
|
||||
full_compact = f"{full_parts[1]} {full_parts[2]} {full_parts[3]}"
|
||||
new_compact = f"{new_parts[1]} {new_parts[2]} {new_parts[3]}"
|
||||
response += f"\nFull:{full_compact} New:{new_compact}"
|
||||
# Create compact response with full/new moon dates
|
||||
return self.translate('commands.moon.format_with_dates',
|
||||
phase=phase, illum=illum,
|
||||
rise=rise_info, set=set_info,
|
||||
full=full_compact, new=new_compact)
|
||||
|
||||
return response
|
||||
# Create compact response without full/new moon dates
|
||||
return self.translate('commands.moon.format',
|
||||
phase=phase, illum=illum,
|
||||
rise=rise_info, set=set_info)
|
||||
else:
|
||||
# Fallback to original format if parsing fails
|
||||
return f"🌙 {moon_info}"
|
||||
return self.translate('commands.moon.fallback', info=moon_info)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to original format if formatting fails
|
||||
return f"🌙 {moon_info}"
|
||||
return self.translate('commands.moon.fallback', info=moon_info)
|
||||
|
||||
def get_help_text(self):
|
||||
"""Get help text for this command"""
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multitest command for the MeshCore Bot
|
||||
Listens for a period of time and collects all unique paths from incoming messages
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Set, Optional
|
||||
from .base_command import BaseCommand
|
||||
from ..models import MeshMessage
|
||||
from ..utils import calculate_packet_hash
|
||||
|
||||
|
||||
class MultitestCommand(BaseCommand):
|
||||
"""Handles the multitest command - listens for multiple path variations"""
|
||||
|
||||
# Plugin metadata
|
||||
name = "multitest"
|
||||
keywords = ['multitest', 'mt']
|
||||
description = "Listens for 6 seconds and collects all unique paths from incoming messages"
|
||||
category = "meshcore_info"
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
self.listening = False
|
||||
self.collected_paths: Set[str] = set()
|
||||
self.listening_start_time = 0
|
||||
self.listening_duration = 6.0 # 6 seconds listening window
|
||||
self.target_packet_hash: Optional[str] = None # Hash of the message we're tracking
|
||||
self.triggering_timestamp: float = 0.0 # Timestamp of the triggering message
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.translate('commands.multitest.help', fallback="Listens for 6 seconds and collects all unique paths from incoming messages")
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Check if message matches multitest keyword"""
|
||||
content = message.content.strip()
|
||||
|
||||
# Handle exclamation prefix
|
||||
if content.startswith('!'):
|
||||
content = content[1:].strip()
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Check for exact match or keyword followed by space
|
||||
for keyword in self.keywords:
|
||||
if content_lower == keyword or content_lower.startswith(keyword + ' '):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def extract_path_from_rf_data(self, rf_data: dict) -> Optional[str]:
|
||||
"""Extract path in prefix string format from RF data routing_info"""
|
||||
try:
|
||||
routing_info = rf_data.get('routing_info')
|
||||
if not routing_info:
|
||||
return None
|
||||
|
||||
path_nodes = routing_info.get('path_nodes', [])
|
||||
if not path_nodes:
|
||||
# Try to extract from path_hex if path_nodes not available
|
||||
path_hex = routing_info.get('path_hex', '')
|
||||
if path_hex:
|
||||
# Convert hex string to node list (every 2 characters = 1 node)
|
||||
path_nodes = [path_hex[i:i+2] for i in range(0, len(path_hex), 2)]
|
||||
|
||||
if path_nodes:
|
||||
# Validate and format path nodes
|
||||
valid_parts = []
|
||||
for node in path_nodes:
|
||||
# Convert to string if needed
|
||||
node_str = str(node).lower().strip()
|
||||
# Check if it's a 2-character hex value
|
||||
if len(node_str) == 2 and all(c in '0123456789abcdef' for c in node_str):
|
||||
valid_parts.append(node_str)
|
||||
|
||||
if valid_parts:
|
||||
return ','.join(valid_parts)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error extracting path from RF data: {e}")
|
||||
return None
|
||||
|
||||
def extract_path_from_message(self, message: MeshMessage) -> Optional[str]:
|
||||
"""Extract path in prefix string format from a message"""
|
||||
if not message.path:
|
||||
return None
|
||||
|
||||
# Check if it's a direct connection
|
||||
if "Direct" in message.path or "0 hops" in message.path:
|
||||
return None
|
||||
|
||||
# Try to extract path nodes from the path string
|
||||
# Path strings are typically in format: "node1,node2,node3 via ROUTE_TYPE_*"
|
||||
# or just "node1,node2,node3"
|
||||
path_string = message.path
|
||||
|
||||
# Remove route type suffix if present
|
||||
if " via ROUTE_TYPE_" in path_string:
|
||||
path_string = path_string.split(" via ROUTE_TYPE_")[0]
|
||||
|
||||
# Check if it looks like a comma-separated path
|
||||
if ',' in path_string:
|
||||
# Clean up any extra info (like hop counts in parentheses)
|
||||
# Example: "01,7e,55,86 (4 hops)" -> "01,7e,55,86"
|
||||
if '(' in path_string:
|
||||
path_string = path_string.split('(')[0].strip()
|
||||
|
||||
# Validate that all parts are 2-character hex values
|
||||
parts = path_string.split(',')
|
||||
valid_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
# Check if it's a 2-character hex value
|
||||
if len(part) == 2 and all(c in '0123456789abcdefABCDEF' for c in part):
|
||||
valid_parts.append(part.lower())
|
||||
|
||||
if valid_parts:
|
||||
return ','.join(valid_parts)
|
||||
|
||||
return None
|
||||
|
||||
def get_rf_data_for_message(self, message: MeshMessage) -> Optional[dict]:
|
||||
"""Get RF data for a message by looking it up in recent RF data"""
|
||||
try:
|
||||
# Try multiple correlation strategies
|
||||
# Strategy 1: Use sender_pubkey to find recent RF data
|
||||
if message.sender_pubkey:
|
||||
# Try full pubkey first
|
||||
recent_rf_data = self.bot.message_handler.find_recent_rf_data(message.sender_pubkey)
|
||||
if recent_rf_data:
|
||||
return recent_rf_data
|
||||
|
||||
# Try pubkey prefix (first 16 chars)
|
||||
if len(message.sender_pubkey) >= 16:
|
||||
pubkey_prefix = message.sender_pubkey[:16]
|
||||
recent_rf_data = self.bot.message_handler.find_recent_rf_data(pubkey_prefix)
|
||||
if recent_rf_data:
|
||||
return recent_rf_data
|
||||
|
||||
# Strategy 2: Look through recent RF data for matching pubkey
|
||||
if message.sender_pubkey and self.bot.message_handler.recent_rf_data:
|
||||
# Search recent RF data for matching pubkey
|
||||
for rf_data in reversed(self.bot.message_handler.recent_rf_data):
|
||||
rf_pubkey = rf_data.get('pubkey_prefix', '')
|
||||
if rf_pubkey and message.sender_pubkey.startswith(rf_pubkey):
|
||||
return rf_data
|
||||
|
||||
# Strategy 3: Use most recent RF data as fallback
|
||||
# This is less reliable but might work if timing is very close
|
||||
if self.bot.message_handler.recent_rf_data:
|
||||
# Get the most recent RF data entry within a short time window
|
||||
current_time = time.time()
|
||||
recent_entries = [
|
||||
rf for rf in self.bot.message_handler.recent_rf_data
|
||||
if current_time - rf.get('timestamp', 0) < 5.0 # Within last 5 seconds
|
||||
]
|
||||
if recent_entries:
|
||||
most_recent = max(recent_entries, key=lambda x: x.get('timestamp', 0))
|
||||
return most_recent
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error getting RF data for message: {e}")
|
||||
return None
|
||||
|
||||
def on_message_received(self, message: MeshMessage):
|
||||
"""Callback method called by message handler when a message is received during listening"""
|
||||
if not self.listening or not self.target_packet_hash:
|
||||
return
|
||||
|
||||
# Check if we're still in the listening window
|
||||
elapsed = time.time() - self.listening_start_time
|
||||
if elapsed >= self.listening_duration:
|
||||
return
|
||||
|
||||
# Get RF data for this message (contains pre-calculated packet hash)
|
||||
rf_data = self.get_rf_data_for_message(message)
|
||||
if not rf_data:
|
||||
# Can't get RF data, skip this message
|
||||
self.logger.debug(f"Skipping message - no RF data found (sender: {message.sender_id})")
|
||||
return
|
||||
|
||||
# Use pre-calculated packet hash if available, otherwise calculate it
|
||||
message_hash = rf_data.get('packet_hash')
|
||||
if not message_hash and rf_data.get('raw_hex'):
|
||||
# Fallback: calculate hash if not stored (for older RF data)
|
||||
try:
|
||||
payload_type = None
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
if routing_info:
|
||||
# Try to get payload type from routing_info if available
|
||||
payload_type = routing_info.get('payload_type')
|
||||
message_hash = calculate_packet_hash(rf_data['raw_hex'], payload_type)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error calculating packet hash: {e}")
|
||||
message_hash = None
|
||||
|
||||
if not message_hash:
|
||||
# Can't determine hash, skip this message
|
||||
self.logger.debug(f"Skipping message - could not determine packet hash (sender: {message.sender_id})")
|
||||
return
|
||||
|
||||
# CRITICAL: Only collect paths if this message has the same hash as the target
|
||||
# This ensures we only track variations of the same original message
|
||||
if message_hash == self.target_packet_hash:
|
||||
# Try to extract path from RF data first (more reliable)
|
||||
path = self.extract_path_from_rf_data(rf_data)
|
||||
|
||||
# Fallback to message path if RF data extraction failed
|
||||
if not path:
|
||||
path = self.extract_path_from_message(message)
|
||||
|
||||
if path:
|
||||
self.collected_paths.add(path)
|
||||
self.logger.info(f"✓ Collected path: {path} (hash: {message_hash[:8]}...)")
|
||||
else:
|
||||
# Log when we have a matching hash but can't extract path
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
path_length = routing_info.get('path_length', 0)
|
||||
if path_length == 0:
|
||||
self.logger.debug(f"Matched hash {message_hash[:8]}... but path is direct (0 hops)")
|
||||
else:
|
||||
self.logger.debug(f"Matched hash {message_hash[:8]}... but couldn't extract path from routing_info: {routing_info}")
|
||||
else:
|
||||
# Log hash mismatches for debugging (but limit to avoid spam)
|
||||
self.logger.debug(f"✗ Hash mismatch - target: {self.target_packet_hash[:8]}..., received: {message_hash[:8]}... (sender: {message.sender_id})")
|
||||
|
||||
def _scan_recent_rf_data(self):
|
||||
"""Scan recent RF data for packets with matching hash (for messages that haven't been processed yet)"""
|
||||
if not self.target_packet_hash:
|
||||
return
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
matching_count = 0
|
||||
mismatching_count = 0
|
||||
|
||||
# Look at RF data from the last few seconds (before listening started, in case packets arrived just before)
|
||||
for rf_data in self.bot.message_handler.recent_rf_data:
|
||||
# Check if this RF data is recent enough
|
||||
rf_timestamp = rf_data.get('timestamp', 0)
|
||||
time_diff = current_time - rf_timestamp
|
||||
|
||||
# Only include RF data from the triggering message timestamp onwards
|
||||
# This prevents collecting packets from earlier messages that happen to have the same hash
|
||||
rf_timestamp = rf_data.get('timestamp', 0)
|
||||
if rf_timestamp >= self.triggering_timestamp and time_diff <= self.listening_duration:
|
||||
packet_hash = rf_data.get('packet_hash')
|
||||
|
||||
# CRITICAL: Only process if hash matches exactly and is not None/empty
|
||||
if packet_hash and packet_hash == self.target_packet_hash:
|
||||
matching_count += 1
|
||||
# Extract path from this RF data
|
||||
path = self.extract_path_from_rf_data(rf_data)
|
||||
if path:
|
||||
self.collected_paths.add(path)
|
||||
self.logger.info(f"✓ Collected path from RF scan: {path} (hash: {packet_hash[:8]}..., time: {time_diff:.2f}s)")
|
||||
else:
|
||||
self.logger.debug(f"Matched hash {packet_hash[:8]}... in RF scan but couldn't extract path")
|
||||
elif packet_hash:
|
||||
mismatching_count += 1
|
||||
# Only log first few mismatches to avoid spam
|
||||
if mismatching_count <= 3:
|
||||
self.logger.debug(f"✗ RF scan hash mismatch - target: {self.target_packet_hash[:8]}..., found: {packet_hash[:8]}... (time: {time_diff:.2f}s)")
|
||||
|
||||
if matching_count > 0 or mismatching_count > 0:
|
||||
self.logger.debug(f"RF scan complete: {matching_count} matching, {mismatching_count} mismatching packets")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error scanning recent RF data: {e}")
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the multitest command"""
|
||||
self.logger.info("Multitest command executed - starting listening window")
|
||||
|
||||
# Get RF data for the triggering message (contains pre-calculated packet hash)
|
||||
rf_data = self.get_rf_data_for_message(message)
|
||||
if not rf_data:
|
||||
response = "Error: Could not find packet data for this message. Please try again."
|
||||
await self.send_response(message, response)
|
||||
return True
|
||||
|
||||
# Use pre-calculated packet hash if available, otherwise calculate it
|
||||
packet_hash = rf_data.get('packet_hash')
|
||||
if not packet_hash and rf_data.get('raw_hex'):
|
||||
# Fallback: calculate hash if not stored (for older RF data)
|
||||
# IMPORTANT: Must use same payload_type that was used during ingestion
|
||||
payload_type = None
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
if routing_info:
|
||||
payload_type = routing_info.get('payload_type')
|
||||
packet_hash = calculate_packet_hash(rf_data['raw_hex'], payload_type)
|
||||
|
||||
if not packet_hash:
|
||||
response = "Error: Could not calculate packet hash for this message. Please try again."
|
||||
await self.send_response(message, response)
|
||||
return True
|
||||
|
||||
# Store the packet hash to track
|
||||
self.target_packet_hash = packet_hash
|
||||
|
||||
# Store the timestamp of the triggering message to avoid collecting older packets
|
||||
triggering_rf_timestamp = rf_data.get('timestamp', time.time())
|
||||
self.triggering_timestamp = triggering_rf_timestamp
|
||||
|
||||
self.logger.info(f"Tracking packet hash: {self.target_packet_hash[:16]}... (full: {self.target_packet_hash})")
|
||||
self.logger.debug(f"Triggering message timestamp: {triggering_rf_timestamp}")
|
||||
|
||||
# Also extract path from the triggering message itself
|
||||
initial_path = self.extract_path_from_message(message)
|
||||
# Also try to extract from RF data (more reliable)
|
||||
if not initial_path and rf_data:
|
||||
initial_path = self.extract_path_from_rf_data(rf_data)
|
||||
|
||||
if initial_path:
|
||||
self.logger.debug(f"Initial path from triggering message: {initial_path}")
|
||||
|
||||
# Register this command instance as the active listener
|
||||
# Store reference in message handler so it can call on_message_received
|
||||
self.bot.message_handler.multitest_listener = self
|
||||
|
||||
# Start listening
|
||||
self.listening = True
|
||||
self.collected_paths = set()
|
||||
if initial_path:
|
||||
self.collected_paths.add(initial_path) # Include the initial path
|
||||
self.listening_start_time = time.time()
|
||||
|
||||
# Also scan recent RF data for matching hashes (in case messages haven't been processed yet)
|
||||
# But only include packets that arrived at or after the triggering message
|
||||
self._scan_recent_rf_data()
|
||||
|
||||
try:
|
||||
# Wait for the listening duration
|
||||
await asyncio.sleep(self.listening_duration)
|
||||
finally:
|
||||
# Stop listening and unregister (but keep target_packet_hash for error messages)
|
||||
self.listening = False
|
||||
self.bot.message_handler.multitest_listener = None
|
||||
|
||||
# Do a final scan of RF data in case any matching packets arrived
|
||||
self._scan_recent_rf_data()
|
||||
|
||||
# Store hash for error message before clearing it
|
||||
tracking_hash = self.target_packet_hash
|
||||
|
||||
# Format the collected paths
|
||||
if self.collected_paths:
|
||||
# Sort paths for consistent output
|
||||
sorted_paths = sorted(self.collected_paths)
|
||||
response = f"Found {len(sorted_paths)} unique path(s):\n" + "\n".join(sorted_paths)
|
||||
else:
|
||||
# Provide more helpful error message with diagnostic info
|
||||
matching_packets = 0
|
||||
if self.bot.message_handler.recent_rf_data and tracking_hash:
|
||||
for rf_data in self.bot.message_handler.recent_rf_data:
|
||||
if rf_data.get('packet_hash') == tracking_hash:
|
||||
matching_packets += 1
|
||||
|
||||
if tracking_hash is None:
|
||||
response = ("Error: Could not determine packet hash for tracking. "
|
||||
"The triggering message may not have valid packet data.")
|
||||
elif matching_packets > 0:
|
||||
response = (f"No paths extracted from {matching_packets} matching packet(s) "
|
||||
f"(hash: {tracking_hash}). "
|
||||
f"Packets may be direct (0 hops) or path extraction failed.")
|
||||
else:
|
||||
response = (f"No matching packets found during {self.listening_duration}s window. "
|
||||
f"Tracking hash: {tracking_hash}. ")
|
||||
|
||||
# Clear the hash after we're done with it
|
||||
self.target_packet_hash = None
|
||||
|
||||
# Wait for bot TX rate limiter cooldown to expire before sending
|
||||
# This ensures we respond even if another command put the bot on cooldown
|
||||
await self.bot.bot_tx_rate_limiter.wait_for_tx()
|
||||
|
||||
# Also wait for user rate limiter if needed
|
||||
if not self.bot.rate_limiter.can_send():
|
||||
wait_time = self.bot.rate_limiter.time_until_next()
|
||||
if wait_time > 0:
|
||||
self.logger.info(f"Waiting {wait_time:.1f} seconds for rate limiter")
|
||||
await asyncio.sleep(wait_time + 0.1) # Small buffer
|
||||
|
||||
# Send the response
|
||||
await self.send_response(message, response)
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,10 +7,10 @@ Decodes hex path data to show which repeaters were involved in message routing
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
import math
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from .base_command import BaseCommand
|
||||
from ..models import MeshMessage
|
||||
from ..utils import calculate_distance
|
||||
|
||||
|
||||
class PathCommand(BaseCommand):
|
||||
@@ -139,7 +139,7 @@ class PathCommand(BaseCommand):
|
||||
hex_matches = re.findall(hex_pattern, path_input)
|
||||
|
||||
if not hex_matches:
|
||||
return "❌ No valid hex values found in path data. Use format like: 11,98,a4,49,cd,5f,01"
|
||||
return self.translate('commands.path.no_valid_hex')
|
||||
|
||||
# Convert to uppercase for consistency
|
||||
# hex_matches preserves the order from the original path
|
||||
@@ -155,7 +155,7 @@ class PathCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error decoding path: {e}")
|
||||
return f"❌ Error decoding path: {e}"
|
||||
return self.translate('commands.path.error_decoding', error=str(e))
|
||||
|
||||
async def _lookup_repeater_names(self, node_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Look up repeater names for given node IDs"""
|
||||
@@ -347,7 +347,7 @@ class PathCommand(BaseCommand):
|
||||
if public_key.startswith(node_id):
|
||||
# Check if this is a repeater
|
||||
if hasattr(self.bot, 'repeater_manager') and self.bot.repeater_manager._is_repeater_device(contact_data):
|
||||
name = contact_data.get('adv_name', contact_data.get('name', 'Unknown'))
|
||||
name = contact_data.get('adv_name', contact_data.get('name', self.translate('commands.path.unknown_name')))
|
||||
device_matches.append({
|
||||
'name': name,
|
||||
'public_key': public_key,
|
||||
@@ -414,23 +414,6 @@ class PathCommand(BaseCommand):
|
||||
self.logger.warning(f"Could not get API cache data: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate haversine distance between two points in kilometers"""
|
||||
# Convert latitude and longitude from degrees to radians
|
||||
lat1_rad = math.radians(lat1)
|
||||
lon1_rad = math.radians(lon1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
lon2_rad = math.radians(lon2)
|
||||
|
||||
# Haversine formula
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius = 6371.0
|
||||
return earth_radius * c
|
||||
|
||||
def _select_repeater_by_proximity(self, repeaters: List[Dict[str, Any]], node_id: str = None, path_context: List[str] = None) -> Tuple[Optional[Dict[str, Any]], float]:
|
||||
"""
|
||||
@@ -496,7 +479,7 @@ class PathCommand(BaseCommand):
|
||||
# If only one repeater, check if it's within range
|
||||
if len(scored_repeaters) == 1:
|
||||
repeater, recency_score = scored_repeaters[0]
|
||||
distance = self._calculate_distance(
|
||||
distance = calculate_distance(
|
||||
self.bot_latitude, self.bot_longitude,
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
@@ -511,7 +494,7 @@ class PathCommand(BaseCommand):
|
||||
# Calculate combined proximity + recency scores
|
||||
combined_scores = []
|
||||
for repeater, recency_score in scored_repeaters:
|
||||
distance = self._calculate_distance(
|
||||
distance = calculate_distance(
|
||||
self.bot_latitude, self.bot_longitude,
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
@@ -869,13 +852,13 @@ class PathCommand(BaseCommand):
|
||||
|
||||
for repeater, recency_score in scored_repeaters:
|
||||
# Calculate distance to previous node
|
||||
prev_distance = self._calculate_distance(
|
||||
prev_distance = calculate_distance(
|
||||
prev_location[0], prev_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
|
||||
# Calculate distance to next node
|
||||
next_distance = self._calculate_distance(
|
||||
next_distance = calculate_distance(
|
||||
next_location[0], next_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
@@ -896,11 +879,11 @@ class PathCommand(BaseCommand):
|
||||
# Apply maximum range threshold
|
||||
if self.max_proximity_range > 0:
|
||||
# Check if any distance is beyond range
|
||||
prev_dist = self._calculate_distance(
|
||||
prev_dist = calculate_distance(
|
||||
prev_location[0], prev_location[1],
|
||||
best_repeater['latitude'], best_repeater['longitude']
|
||||
)
|
||||
next_dist = self._calculate_distance(
|
||||
next_dist = calculate_distance(
|
||||
next_location[0], next_location[1],
|
||||
best_repeater['latitude'], best_repeater['longitude']
|
||||
)
|
||||
@@ -929,7 +912,7 @@ class PathCommand(BaseCommand):
|
||||
best_combined_score = 0.0
|
||||
|
||||
for repeater, recency_score in scored_repeaters:
|
||||
distance = self._calculate_distance(
|
||||
distance = calculate_distance(
|
||||
reference_location[0], reference_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
@@ -972,15 +955,16 @@ class PathCommand(BaseCommand):
|
||||
if info.get('collision', False):
|
||||
# Multiple repeaters with same prefix
|
||||
matches = info.get('matches', 0)
|
||||
line = f"{node_id}: {matches} repeaters"
|
||||
line = self.translate('commands.path.node_collision', node_id=node_id, matches=matches)
|
||||
elif info.get('geographic_guess', False):
|
||||
# Geographic proximity selection
|
||||
name = info['name']
|
||||
confidence = info.get('confidence', 0.0)
|
||||
|
||||
# Truncate name if too long
|
||||
truncation = self.translate('commands.path.truncation')
|
||||
if len(name) > 20:
|
||||
name = name[:17] + "..."
|
||||
name = name[:17] + truncation
|
||||
|
||||
# Add confidence indicator
|
||||
if confidence >= 0.9:
|
||||
@@ -990,23 +974,25 @@ class PathCommand(BaseCommand):
|
||||
else:
|
||||
confidence_indicator = self.low_confidence_symbol
|
||||
|
||||
line = f"{node_id}: {name} {confidence_indicator}"
|
||||
line = self.translate('commands.path.node_geographic', node_id=node_id, name=name, confidence=confidence_indicator)
|
||||
else:
|
||||
# Single repeater found
|
||||
name = info['name']
|
||||
|
||||
# Truncate name if too long
|
||||
truncation = self.translate('commands.path.truncation')
|
||||
if len(name) > 27:
|
||||
name = name[:24] + "..."
|
||||
name = name[:24] + truncation
|
||||
|
||||
line = f"{node_id}: {name}"
|
||||
line = self.translate('commands.path.node_format', node_id=node_id, name=name)
|
||||
else:
|
||||
# Unknown repeater
|
||||
line = f"{node_id}: Unknown"
|
||||
line = self.translate('commands.path.node_unknown', node_id=node_id)
|
||||
|
||||
# Ensure line fits within 130 character limit
|
||||
if len(line) > 130:
|
||||
line = line[:127] + "..."
|
||||
truncation = self.translate('commands.path.truncation')
|
||||
line = line[:127] + truncation
|
||||
|
||||
lines.append(line)
|
||||
|
||||
@@ -1037,14 +1023,14 @@ class PathCommand(BaseCommand):
|
||||
if current_message:
|
||||
# Add ellipsis on new line to end of continued message (if not the last message)
|
||||
if i < len(lines):
|
||||
current_message += "\n..."
|
||||
current_message += self.translate('commands.path.continuation_end')
|
||||
await self.send_response(message, current_message.rstrip())
|
||||
await asyncio.sleep(3.0) # Delay between messages (same as other commands)
|
||||
message_count += 1
|
||||
|
||||
# Start new message with ellipsis on new line at beginning (if not first message)
|
||||
if message_count > 0:
|
||||
current_message = f"...\n{line}"
|
||||
current_message = self.translate('commands.path.continuation_start', line=line)
|
||||
else:
|
||||
current_message = line
|
||||
else:
|
||||
@@ -1068,7 +1054,7 @@ class PathCommand(BaseCommand):
|
||||
|
||||
# Check if it's a direct connection
|
||||
if "Direct" in path_string or "0 hops" in path_string:
|
||||
return "📡 Direct connection (0 hops)"
|
||||
return self.translate('commands.path.direct_connection')
|
||||
|
||||
# Try to extract path nodes from the path string
|
||||
# Path strings are typically in format: "node1,node2,node3 via ROUTE_TYPE_*"
|
||||
@@ -1084,17 +1070,17 @@ class PathCommand(BaseCommand):
|
||||
return await self._decode_path(path_input)
|
||||
else:
|
||||
# Single node or unknown format
|
||||
return f"📡 Path: {path_string}"
|
||||
return self.translate('commands.path.path_prefix', path_string=path_string)
|
||||
else:
|
||||
return "❌ No path information available in current message"
|
||||
return self.translate('commands.path.no_path')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting path from current message: {e}")
|
||||
return f"❌ Error extracting path from current message: {e}"
|
||||
return self.translate('commands.path.error_extracting', error=str(e))
|
||||
|
||||
def get_help(self) -> str:
|
||||
"""Get help text for the path command"""
|
||||
return """Path: !path [hex] - Decode path to show repeaters. Use !path alone for recent message path, or !path [7e,01] for specific path."""
|
||||
return self.translate('commands.path.help')
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
"""Get help text for the path command (used by help system)"""
|
||||
|
||||
@@ -18,7 +18,7 @@ class PingCommand(BaseCommand):
|
||||
category = "basic"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.description
|
||||
return self.translate('commands.ping.description')
|
||||
|
||||
def get_response_format(self) -> str:
|
||||
"""Get the response format from config"""
|
||||
|
||||
@@ -9,11 +9,10 @@ import aiohttp
|
||||
import time
|
||||
import json
|
||||
import random
|
||||
import math
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from .base_command import BaseCommand
|
||||
from ..models import MeshMessage
|
||||
from ..utils import abbreviate_location, format_location_for_display
|
||||
from ..utils import abbreviate_location, format_location_for_display, calculate_distance
|
||||
|
||||
|
||||
class PrefixCommand(BaseCommand):
|
||||
@@ -59,12 +58,10 @@ class PrefixCommand(BaseCommand):
|
||||
)
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
location_note = self.translate('commands.prefix.location_note') if self.show_repeater_locations else ""
|
||||
if not self.api_url or self.api_url.strip() == "":
|
||||
location_note = " (with city names)" if self.show_repeater_locations else ""
|
||||
return f"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."
|
||||
|
||||
location_note = " (with city names)" if self.show_repeater_locations else ""
|
||||
return f"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'."
|
||||
return self.translate('commands.prefix.help_no_api', location_note=location_note)
|
||||
return self.translate('commands.prefix.help_api', location_note=location_note)
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Check if message starts with 'prefix' keyword"""
|
||||
@@ -97,17 +94,17 @@ class PrefixCommand(BaseCommand):
|
||||
# Handle refresh command
|
||||
if command == "REFRESH":
|
||||
if not self.api_url or self.api_url.strip() == "":
|
||||
response = "❌ Refresh not available - no API URL configured. Using local database only."
|
||||
response = self.translate('commands.prefix.refresh_not_available')
|
||||
return await self.send_response(message, response)
|
||||
await self.refresh_cache()
|
||||
response = "🔄 Repeater prefix cache refreshed!"
|
||||
response = self.translate('commands.prefix.cache_refreshed')
|
||||
return await self.send_response(message, response)
|
||||
|
||||
# Handle free/available command
|
||||
if command == "FREE" or command == "AVAILABLE":
|
||||
free_prefixes, total_free, has_data = await self.get_free_prefixes()
|
||||
if not has_data:
|
||||
response = "❌ Unable to determine free prefixes. Try 'prefix refresh' first."
|
||||
response = self.translate('commands.prefix.unable_determine_free')
|
||||
else:
|
||||
response = self.format_free_prefixes_response(free_prefixes, total_free)
|
||||
return await self.send_response(message, response)
|
||||
@@ -119,14 +116,14 @@ class PrefixCommand(BaseCommand):
|
||||
|
||||
# Validate prefix format
|
||||
if len(command) != 2 or not command.isalnum():
|
||||
response = "❌ Invalid prefix format. Use two characters (e.g., prefix 1A)"
|
||||
response = self.translate('commands.prefix.invalid_format')
|
||||
return await self.send_response(message, response)
|
||||
|
||||
# Get prefix data
|
||||
prefix_data = await self.get_prefix_data(command, include_all=include_all)
|
||||
|
||||
if prefix_data is None:
|
||||
response = f"❌ No repeaters found with prefix '{command}'"
|
||||
response = self.translate('commands.prefix.no_repeaters_found', prefix=command)
|
||||
return await self.send_response(message, response)
|
||||
|
||||
# Add include_all flag to data for formatting
|
||||
@@ -351,7 +348,7 @@ class PrefixCommand(BaseCommand):
|
||||
if (row['latitude'] is not None and
|
||||
row['longitude'] is not None and
|
||||
not (row['latitude'] == 0.0 and row['longitude'] == 0.0)):
|
||||
distance = self._calculate_distance(
|
||||
distance = calculate_distance(
|
||||
self.bot_latitude, self.bot_longitude,
|
||||
row['latitude'], row['longitude']
|
||||
)
|
||||
@@ -365,9 +362,9 @@ class PrefixCommand(BaseCommand):
|
||||
|
||||
# Add device type indicator for clarity
|
||||
if device_type == 2:
|
||||
name += " (Repeater)"
|
||||
name += self.translate('commands.prefix.device_repeater')
|
||||
elif device_type == 3:
|
||||
name += " (Room Server)"
|
||||
name += self.translate('commands.prefix.device_roomserver')
|
||||
|
||||
# Add location information if enabled and available
|
||||
if self.show_repeater_locations:
|
||||
@@ -432,23 +429,6 @@ class PrefixCommand(BaseCommand):
|
||||
self.logger.error(f"Error querying database for prefix '{prefix}': {e}")
|
||||
return None
|
||||
|
||||
def _calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate haversine distance between two points in kilometers"""
|
||||
# Convert latitude and longitude from degrees to radians
|
||||
lat1_rad = math.radians(lat1)
|
||||
lon1_rad = math.radians(lon1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
lon2_rad = math.radians(lon2)
|
||||
|
||||
# Haversine formula
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius = 6371.0
|
||||
return earth_radius * c
|
||||
|
||||
async def get_free_prefixes(self) -> Tuple[List[str], int, bool]:
|
||||
"""Get list of available (unused) prefixes and total count
|
||||
@@ -507,7 +487,7 @@ class PrefixCommand(BaseCommand):
|
||||
if (row.get('latitude') is not None and
|
||||
row.get('longitude') is not None and
|
||||
not (row.get('latitude') == 0.0 and row.get('longitude') == 0.0)):
|
||||
distance = self._calculate_distance(
|
||||
distance = calculate_distance(
|
||||
self.bot_latitude, self.bot_longitude,
|
||||
row['latitude'], row['longitude']
|
||||
)
|
||||
@@ -558,9 +538,9 @@ class PrefixCommand(BaseCommand):
|
||||
def format_free_prefixes_response(self, free_prefixes: List[str], total_free: int) -> str:
|
||||
"""Format the free prefixes response"""
|
||||
if not free_prefixes:
|
||||
return "❌ No free prefixes found (all 254 valid prefixes are in use)"
|
||||
return self.translate('commands.prefix.no_free_prefixes')
|
||||
|
||||
response = f"Available Prefixes ({len(free_prefixes)} of {total_free} free):\n"
|
||||
response = self.translate('commands.prefix.available_prefixes', shown=len(free_prefixes), total=total_free) + "\n"
|
||||
|
||||
# Format as a grid for better readability
|
||||
for i, prefix in enumerate(free_prefixes, 1):
|
||||
@@ -574,7 +554,7 @@ class PrefixCommand(BaseCommand):
|
||||
if len(free_prefixes) % 5 != 0:
|
||||
response += "\n"
|
||||
|
||||
response += f"\n💡 Generate a custom key: https://gessaman.com/mc-keygen"
|
||||
response += "\n" + self.translate('commands.prefix.generate_key')
|
||||
|
||||
return response
|
||||
|
||||
@@ -588,20 +568,23 @@ class PrefixCommand(BaseCommand):
|
||||
# Get bot name for database responses
|
||||
bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
|
||||
|
||||
# Handle pluralization
|
||||
plural = 's' if node_count != 1 else ''
|
||||
|
||||
if source == 'database':
|
||||
# Database response format - keep brief for character limit
|
||||
if include_all:
|
||||
response = f"Prefix {prefix}: {node_count} repeater{'s' if node_count != 1 else ''}\n"
|
||||
response = self.translate('commands.prefix.prefix_db_all', prefix=prefix, count=node_count, plural=plural) + "\n"
|
||||
else:
|
||||
# Show time period for default behavior - use abbreviated form
|
||||
days_str = f"{self.prefix_heard_days}d" if self.prefix_heard_days != 7 else "7d"
|
||||
response = f"Prefix {prefix}: {node_count} repeater{'s' if node_count != 1 else ''} ({days_str})\n"
|
||||
response = self.translate('commands.prefix.prefix_db_recent', prefix=prefix, count=node_count, plural=plural, days=days_str) + "\n"
|
||||
else:
|
||||
# API response format
|
||||
response = f"📡 Prefix {prefix} ({node_count} repeater{'s' if node_count != 1 else ''}):\n"
|
||||
response = self.translate('commands.prefix.prefix_api', prefix=prefix, count=node_count, plural=plural) + "\n"
|
||||
|
||||
for i, name in enumerate(node_names, 1):
|
||||
response += f"{i}. {name}\n"
|
||||
response += self.translate('commands.prefix.item_format', index=i, name=name) + "\n"
|
||||
|
||||
# Add source info (unless hidden by config)
|
||||
if not self.hide_source:
|
||||
@@ -614,10 +597,10 @@ class PrefixCommand(BaseCommand):
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(self.api_url)
|
||||
domain = parsed_url.netloc
|
||||
response += f"\nSource: {domain}"
|
||||
response += "\n" + self.translate('commands.prefix.source_domain', domain=domain)
|
||||
except Exception:
|
||||
# Fallback if URL parsing fails
|
||||
response += f"\nSource: API"
|
||||
response += "\n" + self.translate('commands.prefix.source_api')
|
||||
else:
|
||||
# Remove trailing newline when source is hidden
|
||||
response = response.rstrip('\n')
|
||||
|
||||
@@ -20,7 +20,7 @@ class RollCommand(BaseCommand):
|
||||
category = "games"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Roll a random number between 1 and X (default 100). Use 'roll' for 1-100, 'roll 50' for 1-50, etc."
|
||||
return self.translate('commands.roll.help')
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Override to handle roll-specific matching"""
|
||||
@@ -70,7 +70,7 @@ class RollCommand(BaseCommand):
|
||||
|
||||
def format_roll_result(self, max_num: int, result: int) -> str:
|
||||
"""Format roll result into a readable string"""
|
||||
return f"🎲 Roll 1-{max_num}: {result}"
|
||||
return self.translate('commands.roll.result', max=max_num, result=result)
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the roll command"""
|
||||
@@ -90,7 +90,7 @@ class RollCommand(BaseCommand):
|
||||
|
||||
if max_num is None:
|
||||
# Invalid roll specification
|
||||
response = "Invalid number. Use 'roll' for 1-100, or 'roll X' for 1-X (max 10000)."
|
||||
response = self.translate('commands.roll.invalid_number')
|
||||
return await self.send_response(message, response)
|
||||
|
||||
# Roll the number
|
||||
|
||||
@@ -44,7 +44,7 @@ class SatpassCommand(BaseCommand):
|
||||
# Extract satellite identifier from command
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
error_msg = "Please provide a satellite NORAD number or shortcut. Example: satpass iss"
|
||||
error_msg = self.translate('commands.satpass.no_satellite')
|
||||
await self.send_response(message, error_msg)
|
||||
return True
|
||||
|
||||
@@ -68,18 +68,18 @@ class SatpassCommand(BaseCommand):
|
||||
pass_info = get_next_satellite_pass(satellite, use_visual=use_visual)
|
||||
|
||||
# Send response
|
||||
response = f"🛰️ Satellite Pass:\n{pass_info}"
|
||||
response = self.translate('commands.satpass.header', pass_info=pass_info)
|
||||
await self.send_response(message, response)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error getting satellite pass info: {e}"
|
||||
error_msg = self.translate('commands.satpass.error', error=str(e))
|
||||
await self.send_response(message, error_msg)
|
||||
return False
|
||||
|
||||
def _get_help_text(self):
|
||||
"""Get detailed help text with shortcuts"""
|
||||
shortcuts_text = "🛰️ Satellite Pass Info\n\nUsage: satpass <NORAD_number_or_shortcut>\n\nShortcuts:\n"
|
||||
shortcuts_text = self.translate('commands.satpass.help_header')
|
||||
|
||||
# Group shortcuts by category for better organization
|
||||
weather_sats = ['noaa15', 'noaa18', 'noaa19', 'metop-a', 'metop-b', 'metop-c', 'goes16', 'goes17', 'goes18']
|
||||
@@ -88,29 +88,29 @@ class SatpassCommand(BaseCommand):
|
||||
other = ['starlink']
|
||||
|
||||
# Add weather satellites
|
||||
shortcuts_text += "🌤️ Weather: "
|
||||
shortcuts_text += self.translate('commands.satpass.category_weather')
|
||||
weather_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in weather_sats if name in self.SATELLITE_SHORTCUTS]
|
||||
shortcuts_text += ", ".join(weather_list) + "\n"
|
||||
|
||||
# Add space stations
|
||||
shortcuts_text += "🚀 Stations: "
|
||||
shortcuts_text += self.translate('commands.satpass.category_stations')
|
||||
station_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in space_stations if name in self.SATELLITE_SHORTCUTS]
|
||||
shortcuts_text += ", ".join(station_list) + "\n"
|
||||
|
||||
# Add telescopes
|
||||
shortcuts_text += "🔭 Telescopes: "
|
||||
shortcuts_text += self.translate('commands.satpass.category_telescopes')
|
||||
telescope_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in telescopes if name in self.SATELLITE_SHORTCUTS]
|
||||
shortcuts_text += ", ".join(telescope_list) + "\n"
|
||||
|
||||
# Add other satellites
|
||||
shortcuts_text += "🛰️ Other: "
|
||||
shortcuts_text += self.translate('commands.satpass.category_other')
|
||||
other_list = [f"{name} ({self.SATELLITE_SHORTCUTS[name]})" for name in other if name in self.SATELLITE_SHORTCUTS]
|
||||
shortcuts_text += ", ".join(other_list) + "\n"
|
||||
|
||||
shortcuts_text += "\nExamples:\n• satpass iss\n• satpass noaa15\n• satpass 25544\n• satpass 27607 visual (visual passes only)"
|
||||
shortcuts_text += self.translate('commands.satpass.examples')
|
||||
|
||||
return shortcuts_text
|
||||
|
||||
def get_help_text(self):
|
||||
"""Get help text for this command"""
|
||||
return self.description
|
||||
return self.translate('commands.satpass.description')
|
||||
@@ -27,12 +27,12 @@ class SolarCommand(BaseCommand):
|
||||
solar_info = solar_conditions()
|
||||
|
||||
# Send response (solar only, more readable)
|
||||
response = f"☀️ Solar: {solar_info}"
|
||||
response = self.translate('commands.solar.response', info=solar_info)
|
||||
|
||||
# Use the unified send_response method
|
||||
return await self.send_response(message, response)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error getting solar info: {e}"
|
||||
error_msg = self.translate('commands.solar.error', error=str(e))
|
||||
await self.send_response(message, error_msg)
|
||||
return False
|
||||
@@ -27,9 +27,9 @@ class SolarforecastCommand(BaseCommand):
|
||||
category = "solar"
|
||||
cooldown_seconds = 10 # 10 second cooldown per user
|
||||
|
||||
# Error constants
|
||||
ERROR_FETCHING_DATA = "Error fetching forecast"
|
||||
NO_DATA_AVAILABLE = "No forecast data"
|
||||
# Error constants - will use translations instead
|
||||
ERROR_FETCHING_DATA = "Error fetching forecast" # Deprecated - use translate
|
||||
NO_DATA_AVAILABLE = "No forecast data" # Deprecated - use translate
|
||||
|
||||
# Forecast.Solar minimum panel size (10W)
|
||||
MIN_KWP = 0.01
|
||||
@@ -57,7 +57,18 @@ class SolarforecastCommand(BaseCommand):
|
||||
self.db_manager = bot.db_manager
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return f"Usage: sf <location|repeater_name|coordinates|zipcode> [watts] [azimuth, 0=south] [angle] - Example: sf seattle 10 0 37"
|
||||
return self.translate('commands.solarforecast.usage')
|
||||
|
||||
def _translate_day_abbreviation(self, day_abbr: str) -> str:
|
||||
"""Translate English day abbreviation to localized version"""
|
||||
# Map English abbreviations to translation keys in common.date_time
|
||||
translation_key = f'common.date_time.day_abbreviations.{day_abbr}'
|
||||
translated = self.translate(translation_key)
|
||||
# If translation found (not just the key), return it
|
||||
if translated != translation_key:
|
||||
return translated
|
||||
# Fallback: return original if no translation found
|
||||
return day_abbr
|
||||
|
||||
def can_execute(self, message: MeshMessage) -> bool:
|
||||
"""Override cooldown check to be per-user"""
|
||||
@@ -88,7 +99,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
# Parse command: sf <location> [panel_size] [azimuth] [angle]
|
||||
parts = content.split()
|
||||
if len(parts) < 2:
|
||||
await self.send_response(message, "Usage: sf <location|repeater_name|coordinates|zipcode> [W] [azimuth] [angle] - Example: sf seattle 10")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -107,7 +118,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
num_value = float(parts[1])
|
||||
if not (len(parts[1]) == 5 and parts[1].isdigit()):
|
||||
# Single non-5-digit number - invalid, no location provided
|
||||
await self.send_response(message, "Usage: sf <location|repeater_name|coordinates|zipcode> [W] [azimuth] [angle] - Example: sf seattle 10")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
||||
return True
|
||||
except ValueError:
|
||||
pass # Not a number, continue with normal parsing
|
||||
@@ -139,7 +150,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
|
||||
# Validate that we have a location
|
||||
if not location_str:
|
||||
await self.send_response(message, "Usage: sf <location|repeater_name|coordinates|zipcode> [W] [azimuth] [angle] - Example: sf seattle 10")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
||||
return True
|
||||
|
||||
# Parse optional parameters
|
||||
@@ -154,7 +165,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
panel_str = parts[param_start_idx].strip().rstrip('wW')
|
||||
panel_watts = float(panel_str)
|
||||
if panel_watts <= 0 or panel_watts > 1000:
|
||||
await self.send_response(message, "Panel size must be 1-1000W")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.panel_size_range'))
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -164,7 +175,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
try:
|
||||
azimuth = float(parts[param_start_idx + 1])
|
||||
if not (-180 <= azimuth <= 180):
|
||||
await self.send_response(message, "Azimuth must be -180 to 180 (0=south)")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.azimuth_range'))
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -174,7 +185,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
try:
|
||||
angle = float(parts[param_start_idx + 2])
|
||||
if not (0 <= angle <= 90):
|
||||
await self.send_response(message, "Angle must be 0-90 degrees")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.angle_range'))
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -182,7 +193,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
# Parse location (check for repeater name first, then coordinates, zip, or city)
|
||||
lat, lon, location_type = await self._parse_location(location_str)
|
||||
if lat is None or lon is None:
|
||||
await self.send_response(message, f"Could not find location: {location_str}")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.no_location', location=location_str))
|
||||
return True
|
||||
|
||||
# Get location name for confirmation
|
||||
@@ -198,7 +209,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in solar forecast command: {e}")
|
||||
await self.send_response(message, f"Error: {e}")
|
||||
await self.send_response(message, self.translate('commands.solarforecast.error', error=str(e)))
|
||||
return True
|
||||
|
||||
def _clean_location_string(self, location: str) -> str:
|
||||
@@ -527,18 +538,18 @@ class SolarforecastCommand(BaseCommand):
|
||||
result = await self._query_forecast_solar_scaled(lat, lon, angle, azimuth, kwp, api_key)
|
||||
|
||||
if not result:
|
||||
return self.ERROR_FETCHING_DATA
|
||||
return self.translate('commands.solarforecast.error_fetching')
|
||||
|
||||
# Check for rate limiting
|
||||
if result.get('rate_limited'):
|
||||
return "Rate limit: Try again in 5 min"
|
||||
return self.translate('commands.solarforecast.rate_limit')
|
||||
|
||||
# Format output to fit 130 characters
|
||||
return self._format_forecast(result, panel_watts, location_name, lat, lon)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting forecast: {e}")
|
||||
return f"Error: {e}"
|
||||
return self.translate('commands.solarforecast.error', error=str(e))
|
||||
|
||||
def _get_cache_key(self, lat: float, lon: float, declination: float,
|
||||
azimuth: float, kwp: float, api_key: Optional[str]) -> str:
|
||||
@@ -702,7 +713,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
num_days = result.get('num_days', 0)
|
||||
|
||||
if not watt_hours_day:
|
||||
return self.NO_DATA_AVAILABLE
|
||||
return self.translate('commands.solarforecast.no_data')
|
||||
|
||||
# Get timezone from config or use system timezone
|
||||
timezone_str = self.bot.config.get('Bot', 'timezone', fallback='')
|
||||
@@ -728,8 +739,11 @@ class SolarforecastCommand(BaseCommand):
|
||||
# Get day names for Day+2 and Day+3
|
||||
day_after_date = now + timedelta(days=2)
|
||||
day_after_2_date = now + timedelta(days=3)
|
||||
day_after_name = day_after_date.strftime('%a') # Mon, Tue, Wed, etc.
|
||||
day_after_2_name = day_after_2_date.strftime('%a')
|
||||
day_after_name_en = day_after_date.strftime('%a') # Mon, Tue, Wed, etc.
|
||||
day_after_2_name_en = day_after_2_date.strftime('%a')
|
||||
# Translate day abbreviations
|
||||
day_after_name = self._translate_day_abbreviation(day_after_name_en)
|
||||
day_after_2_name = self._translate_day_abbreviation(day_after_2_name_en)
|
||||
|
||||
# Build user-friendly forecast with peak grouped by day
|
||||
day_parts = []
|
||||
@@ -929,11 +943,11 @@ class SolarforecastCommand(BaseCommand):
|
||||
pass
|
||||
today_prod_hours = len(unique_hours)
|
||||
|
||||
today_part = f"Today:{today_wh:.0f}Wh"
|
||||
today_part = self.translate('commands.solarforecast.labels.today', wh=today_wh)
|
||||
if today_prod_hours > 0:
|
||||
if first_day_utilization is not None and first_day_date == today:
|
||||
# First day - show %util
|
||||
today_part += f" ({today_prod_hours}h,{first_day_utilization:.0f}%util)"
|
||||
today_part += self.translate('commands.solarforecast.labels.hours_util', hours=today_prod_hours, util=first_day_utilization)
|
||||
else:
|
||||
# Calculate utilization for this day
|
||||
# Use 100% of panel capacity - API already accounts for real-world conditions
|
||||
@@ -941,13 +955,13 @@ class SolarforecastCommand(BaseCommand):
|
||||
typical_max_energy = typical_max_power * today_prod_hours
|
||||
if typical_max_energy > 0:
|
||||
utilization = (today_wh / typical_max_energy) * 100
|
||||
today_part += f" ({today_prod_hours}h,{utilization:.0f}%)"
|
||||
today_part += self.translate('commands.solarforecast.labels.hours_percent', hours=today_prod_hours, percent=utilization)
|
||||
else:
|
||||
today_part += f" ({today_prod_hours}h)"
|
||||
today_part += self.translate('commands.solarforecast.labels.hours_only', hours=today_prod_hours)
|
||||
|
||||
# Add peak if it's today
|
||||
if peak_date == today and peak_time_str:
|
||||
today_part += f" Peak: {max_watts:.0f}W@{peak_time_str}"
|
||||
today_part += " " + self.translate('commands.solarforecast.labels.peak', watts=max_watts, time=peak_time_str)
|
||||
|
||||
# First line includes panel info and location
|
||||
first_line = f"{first_line_prefix}{today_part}"
|
||||
@@ -984,11 +998,11 @@ class SolarforecastCommand(BaseCommand):
|
||||
pass
|
||||
tomorrow_prod_hours = len(unique_hours)
|
||||
|
||||
tomorrow_part = f"Tmrw:{tomorrow_wh:.0f}Wh"
|
||||
tomorrow_part = self.translate('commands.solarforecast.labels.tomorrow', wh=tomorrow_wh)
|
||||
if tomorrow_prod_hours > 0:
|
||||
if first_day_utilization is not None and first_day_date == tomorrow:
|
||||
# First day - show %util
|
||||
tomorrow_part += f" ({tomorrow_prod_hours}h,{first_day_utilization:.0f}%util)"
|
||||
tomorrow_part += self.translate('commands.solarforecast.labels.hours_util', hours=tomorrow_prod_hours, util=first_day_utilization)
|
||||
else:
|
||||
# Calculate utilization for this day
|
||||
# Use 100% of panel capacity - API already accounts for real-world conditions
|
||||
@@ -996,13 +1010,13 @@ class SolarforecastCommand(BaseCommand):
|
||||
typical_max_energy = typical_max_power * tomorrow_prod_hours
|
||||
if typical_max_energy > 0:
|
||||
utilization = (tomorrow_wh / typical_max_energy) * 100
|
||||
tomorrow_part += f" ({tomorrow_prod_hours}h,{utilization:.0f}%)"
|
||||
tomorrow_part += self.translate('commands.solarforecast.labels.hours_percent', hours=tomorrow_prod_hours, percent=utilization)
|
||||
else:
|
||||
tomorrow_part += f" ({tomorrow_prod_hours}h)"
|
||||
tomorrow_part += self.translate('commands.solarforecast.labels.hours_only', hours=tomorrow_prod_hours)
|
||||
|
||||
# Add peak if it's tomorrow
|
||||
if peak_date == tomorrow and peak_time_str:
|
||||
tomorrow_part += f" Peak: {max_watts:.0f}W@{peak_time_str}"
|
||||
tomorrow_part += " " + self.translate('commands.solarforecast.labels.peak', watts=max_watts, time=peak_time_str)
|
||||
|
||||
lines.append(tomorrow_part)
|
||||
|
||||
@@ -1040,7 +1054,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
pass
|
||||
day_after_prod_hours = len(unique_hours)
|
||||
|
||||
day_after_part = f"{day_after_name}:{day_after_wh:.0f}Wh"
|
||||
day_after_part = self.translate('commands.solarforecast.labels.day_format', day=day_after_name, wh=day_after_wh)
|
||||
if day_after_prod_hours > 0:
|
||||
# Calculate utilization for this day
|
||||
# Use 100% of panel capacity - API already accounts for real-world conditions
|
||||
@@ -1048,9 +1062,9 @@ class SolarforecastCommand(BaseCommand):
|
||||
typical_max_energy = typical_max_power * day_after_prod_hours
|
||||
if typical_max_energy > 0:
|
||||
utilization = (day_after_wh / typical_max_energy) * 100
|
||||
day_after_part += f" ({day_after_prod_hours}h,{utilization:.0f}%)"
|
||||
day_after_part += self.translate('commands.solarforecast.labels.hours_percent', hours=day_after_prod_hours, percent=utilization)
|
||||
else:
|
||||
day_after_part += f" ({day_after_prod_hours}h)"
|
||||
day_after_part += self.translate('commands.solarforecast.labels.hours_only', hours=day_after_prod_hours)
|
||||
|
||||
# Peak is only shown for today or tomorrow, not for later days
|
||||
|
||||
@@ -1087,7 +1101,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
pass
|
||||
day_after_2_prod_hours = len(unique_hours)
|
||||
|
||||
day_after_2_part = f"{day_after_2_name}:{day_after_2_wh:.0f}Wh"
|
||||
day_after_2_part = self.translate('commands.solarforecast.labels.day_format', day=day_after_2_name, wh=day_after_2_wh)
|
||||
if day_after_2_prod_hours > 0:
|
||||
# Calculate utilization for this day
|
||||
# Use 100% of panel capacity - API already accounts for real-world conditions
|
||||
@@ -1095,9 +1109,9 @@ class SolarforecastCommand(BaseCommand):
|
||||
typical_max_energy = typical_max_power * day_after_2_prod_hours
|
||||
if typical_max_energy > 0:
|
||||
utilization = (day_after_2_wh / typical_max_energy) * 100
|
||||
day_after_2_part += f" ({day_after_2_prod_hours}h,{utilization:.0f}%)"
|
||||
day_after_2_part += self.translate('commands.solarforecast.labels.hours_percent', hours=day_after_2_prod_hours, percent=utilization)
|
||||
else:
|
||||
day_after_2_part += f" ({day_after_2_prod_hours}h)"
|
||||
day_after_2_part += self.translate('commands.solarforecast.labels.hours_only', hours=day_after_2_prod_hours)
|
||||
|
||||
# Peak is only shown for today or tomorrow, not for later days
|
||||
|
||||
@@ -1105,7 +1119,8 @@ class SolarforecastCommand(BaseCommand):
|
||||
|
||||
# Add Day+2 and Day+3 on same line if both exist
|
||||
if day_plus_line_parts:
|
||||
day_plus_line = " | ".join(day_plus_line_parts)
|
||||
separator = self.translate('commands.solarforecast.labels.separator')
|
||||
day_plus_line = separator.join(day_plus_line_parts)
|
||||
lines.append(day_plus_line)
|
||||
|
||||
# If peak has passed, add it at the end
|
||||
@@ -1118,7 +1133,7 @@ class SolarforecastCommand(BaseCommand):
|
||||
today_watts.append(power)
|
||||
if today_watts:
|
||||
past_max = max(today_watts)
|
||||
lines.append(f"Peak: {past_max:.0f}W (past)")
|
||||
lines.append(self.translate('commands.solarforecast.labels.peak_past', watts=past_max))
|
||||
|
||||
# Join lines with newlines
|
||||
full_message = "\n".join(lines)
|
||||
|
||||
@@ -528,7 +528,7 @@ class SportsCommand(BaseCommand):
|
||||
return True
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Get sports scores & schedules. Use 'sports' for default teams, 'sports [team]' for specific team, or 'sports [league]' for league games."
|
||||
return self.translate('commands.sports.help')
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the sports command"""
|
||||
@@ -563,12 +563,12 @@ class SportsCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in sports command: {e}")
|
||||
return await self.send_response(message, "Error fetching sports data")
|
||||
return await self.send_response(message, self.translate('commands.sports.error_fetching'))
|
||||
|
||||
async def get_default_teams_scores(self) -> str:
|
||||
"""Get scores for default teams, sorted by game time"""
|
||||
if not self.default_teams:
|
||||
return "No default teams configured"
|
||||
return self.translate('commands.sports.no_default_teams')
|
||||
|
||||
game_data = []
|
||||
for team in self.default_teams:
|
||||
@@ -582,7 +582,7 @@ class SportsCommand(BaseCommand):
|
||||
self.logger.warning(f"Error fetching score for {team}: {e}")
|
||||
|
||||
if not game_data:
|
||||
return "No games found for default teams"
|
||||
return self.translate('commands.sports.no_games_default')
|
||||
|
||||
# Sort by game time (earliest first)
|
||||
game_data.sort(key=lambda x: x['timestamp'])
|
||||
@@ -753,7 +753,7 @@ class SportsCommand(BaseCommand):
|
||||
async def get_city_scores(self, city_teams: List[Dict[str, str]], city_name: str) -> str:
|
||||
"""Get scores for all teams in a city"""
|
||||
if not city_teams:
|
||||
return f"No teams found for {city_name}"
|
||||
return self.translate('commands.sports.no_teams_city', city=city_name)
|
||||
|
||||
game_data = []
|
||||
for team_info in city_teams:
|
||||
@@ -765,7 +765,7 @@ class SportsCommand(BaseCommand):
|
||||
self.logger.warning(f"Error fetching score for {team_info}: {e}")
|
||||
|
||||
if not game_data:
|
||||
return f"No games found for {city_name} teams"
|
||||
return self.translate('commands.sports.no_games_city', city=city_name)
|
||||
|
||||
# Sort by game time (earliest first)
|
||||
game_data.sort(key=lambda x: x['timestamp'])
|
||||
@@ -802,7 +802,7 @@ class SportsCommand(BaseCommand):
|
||||
events = data.get('events', [])
|
||||
|
||||
if not events:
|
||||
return f"No games found for {league_info['sport']}"
|
||||
return self.translate('commands.sports.no_games_league', sport=league_info['sport'])
|
||||
|
||||
# Parse all games and sort by time
|
||||
game_data = []
|
||||
@@ -812,7 +812,7 @@ class SportsCommand(BaseCommand):
|
||||
game_data.append(game_info)
|
||||
|
||||
if not game_data:
|
||||
return f"No games found for {league_info['sport']}"
|
||||
return self.translate('commands.sports.no_games_league', sport=league_info['sport'])
|
||||
|
||||
# Sort by game time (earliest first)
|
||||
game_data.sort(key=lambda x: x['timestamp'])
|
||||
@@ -837,7 +837,7 @@ class SportsCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching league scores: {e}")
|
||||
return f"Error fetching {league_info['sport']} data"
|
||||
return self.translate('commands.sports.error_fetching_league', sport=league_info['sport'])
|
||||
|
||||
def parse_league_game_event(self, event: Dict, sport: str, league: str) -> Optional[Dict]:
|
||||
"""Parse a league game event and return structured data with timestamp for sorting"""
|
||||
@@ -1020,7 +1020,7 @@ class SportsCommand(BaseCommand):
|
||||
# Otherwise, treat as single team query
|
||||
team_info = self.TEAM_MAPPINGS.get(team_name)
|
||||
if not team_info:
|
||||
return f"Team/League '{team_name}' not found. Try: seahawks, mariners, sounders, kraken, storm, chiefs, lfc, mlb, nfl, mls, wnba, epl, etc."
|
||||
return self.translate('commands.sports.team_not_found', team=team_name)
|
||||
|
||||
try:
|
||||
score_info = await self.fetch_team_score(team_info)
|
||||
@@ -1029,10 +1029,10 @@ class SportsCommand(BaseCommand):
|
||||
sport_emoji = self.SPORT_EMOJIS.get(team_info['sport'], '🏆')
|
||||
return f"{sport_emoji} {score_info}"
|
||||
else:
|
||||
return f"No games found for {team_name}"
|
||||
return self.translate('commands.sports.no_games_team', team=team_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching score for {team_name}: {e}")
|
||||
return f"Error fetching data for {team_name}"
|
||||
return self.translate('commands.sports.error_fetching_team', team=team_name)
|
||||
|
||||
async def fetch_team_score(self, team_info: Dict[str, str]) -> Optional[str]:
|
||||
"""Fetch score information for a team (legacy method for individual team queries)"""
|
||||
|
||||
@@ -259,12 +259,12 @@ class StatsCommand(BaseCommand):
|
||||
return path
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return "Show 24-hour bot statistics. Commands: 'stats' (basic), 'stats messages' (bot users), 'stats channels' (channel activity), 'stats paths' (longest paths)"
|
||||
return self.translate('commands.stats.help')
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the stats command"""
|
||||
if not self.stats_enabled:
|
||||
await self.send_response(message, "Stats command is disabled")
|
||||
await self.send_response(message, self.translate('commands.stats.disabled'))
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -287,7 +287,7 @@ class StatsCommand(BaseCommand):
|
||||
elif subcommand in ['paths', 'path']:
|
||||
response = await self._get_path_leaderboard()
|
||||
else:
|
||||
response = f"Unknown: {subcommand}. Use 'stats', 'stats messages', 'stats channels', or 'stats paths'"
|
||||
response = self.translate('commands.stats.unknown_subcommand', subcommand=subcommand)
|
||||
else:
|
||||
response = await self._get_basic_stats()
|
||||
|
||||
@@ -298,7 +298,7 @@ class StatsCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing stats command: {e}")
|
||||
await self.send_response(message, f"Error getting stats: {e}")
|
||||
await self.send_response(message, self.translate('commands.stats.error', error=str(e)))
|
||||
return False
|
||||
|
||||
async def _get_basic_stats(self) -> str:
|
||||
@@ -335,7 +335,10 @@ class StatsCommand(BaseCommand):
|
||||
LIMIT 1
|
||||
''', (day_ago,))
|
||||
top_command_result = cursor.fetchone()
|
||||
top_command = f"{top_command_result[0]} ({top_command_result[1]})" if top_command_result else "None"
|
||||
if top_command_result:
|
||||
top_command = f"{top_command_result[0]} ({top_command_result[1]})"
|
||||
else:
|
||||
top_command = self.translate('commands.stats.basic.none')
|
||||
|
||||
# Top user
|
||||
cursor.execute('''
|
||||
@@ -347,18 +350,21 @@ class StatsCommand(BaseCommand):
|
||||
LIMIT 1
|
||||
''', (day_ago,))
|
||||
top_user_result = cursor.fetchone()
|
||||
top_user = f"{top_user_result[0]} ({top_user_result[1]})" if top_user_result else "None"
|
||||
if top_user_result:
|
||||
top_user = f"{top_user_result[0]} ({top_user_result[1]})"
|
||||
else:
|
||||
top_user = self.translate('commands.stats.basic.none')
|
||||
|
||||
response = f"""Bot Stats (24h):
|
||||
Cmds: {commands_received} | Replies: {bot_replies}
|
||||
Top cmd: {top_command}
|
||||
Top user: {top_user}"""
|
||||
response = f"""{self.translate('commands.stats.basic.header')}
|
||||
{self.translate('commands.stats.basic.commands', count=commands_received, replies=bot_replies)}
|
||||
{self.translate('commands.stats.basic.top_command', command=top_command)}
|
||||
{self.translate('commands.stats.basic.top_user', user=top_user)}"""
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting basic stats: {e}")
|
||||
return f"Error getting stats: {e}"
|
||||
return self.translate('commands.stats.error', error=str(e))
|
||||
|
||||
async def _get_bot_user_leaderboard(self) -> str:
|
||||
"""Get leaderboard for bot users (people who triggered bot responses)"""
|
||||
@@ -382,20 +388,20 @@ Top user: {top_user}"""
|
||||
top_users = cursor.fetchall()
|
||||
|
||||
# Build response
|
||||
response = "Bot Users (24h):\n"
|
||||
response = self.translate('commands.stats.users.header') + "\n"
|
||||
|
||||
if top_users:
|
||||
for i, (user, count) in enumerate(top_users, 1):
|
||||
display_user = user[:12] + "..." if len(user) > 15 else user
|
||||
response += f"{i}. {display_user}: {count}\n"
|
||||
else:
|
||||
response += "No bot users\n"
|
||||
response += self.translate('commands.stats.users.none') + "\n"
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting bot user leaderboard: {e}")
|
||||
return f"Error getting bot user stats: {e}"
|
||||
return self.translate('commands.stats.error_bot_users', error=str(e))
|
||||
|
||||
async def _get_channel_leaderboard(self) -> str:
|
||||
"""Get leaderboard for channel message activity"""
|
||||
@@ -419,25 +425,25 @@ Top user: {top_user}"""
|
||||
top_channels = cursor.fetchall()
|
||||
|
||||
# Build compact response
|
||||
response = "Top Channels (24h)\n"
|
||||
response = self.translate('commands.stats.channels.header') + "\n"
|
||||
|
||||
if top_channels:
|
||||
for i, (channel, msg_count, unique_users) in enumerate(top_channels, 1):
|
||||
display_channel = channel[:12] + "..." if len(channel) > 15 else channel
|
||||
# Handle singular/plural for messages and users
|
||||
msg_text = "msg" if msg_count == 1 else "msgs"
|
||||
user_text = "user" if unique_users == 1 else "users"
|
||||
response += f"{i}. {display_channel}: {msg_count} {msg_text} | {unique_users} {user_text}\n"
|
||||
msg_text = self.translate('commands.stats.channels.msg_singular') if msg_count == 1 else self.translate('commands.stats.channels.msg_plural')
|
||||
user_text = self.translate('commands.stats.channels.user_singular') if unique_users == 1 else self.translate('commands.stats.channels.user_plural')
|
||||
response += self.translate('commands.stats.channels.format', rank=i, channel=display_channel, msg_count=msg_count, msg_text=msg_text, user_count=unique_users, user_text=user_text) + "\n"
|
||||
# Remove trailing newline
|
||||
response = response.rstrip('\n')
|
||||
else:
|
||||
response += "No channel activity"
|
||||
response += self.translate('commands.stats.channels.none')
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting channel leaderboard: {e}")
|
||||
return f"Error getting channel stats: {e}"
|
||||
return self.translate('commands.stats.error_channels', error=str(e))
|
||||
|
||||
async def _get_path_leaderboard(self) -> str:
|
||||
"""Get leaderboard for longest paths seen"""
|
||||
@@ -475,7 +481,7 @@ Top user: {top_user}"""
|
||||
# Truncate sender name to fit more data
|
||||
display_sender = sender[:8] + "..." if len(sender) > 11 else sender
|
||||
# Compact format: "1 Gundam 56,1c,98,1a,aa,cd,5f"
|
||||
new_line = f"{i} {display_sender} {path_str}\n"
|
||||
new_line = self.translate('commands.stats.paths.format', rank=i, sender=display_sender, path=path_str) + "\n"
|
||||
|
||||
# Check if adding this line would exceed the limit
|
||||
if len(response + new_line) > max_length:
|
||||
@@ -483,13 +489,13 @@ Top user: {top_user}"""
|
||||
|
||||
response += new_line
|
||||
else:
|
||||
response = "No path data\n"
|
||||
response = self.translate('commands.stats.paths.none') + "\n"
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting path leaderboard: {e}")
|
||||
return f"Error getting path stats: {e}"
|
||||
return self.translate('commands.stats.error_paths', error=str(e))
|
||||
|
||||
def cleanup_old_stats(self, days_to_keep: int = 7):
|
||||
"""Clean up old stats data to prevent database bloat"""
|
||||
|
||||
@@ -22,13 +22,13 @@ class SunCommand(BaseCommand):
|
||||
sun_info = get_sun()
|
||||
|
||||
# Send response using unified method
|
||||
response = f"☀️ Sun Info:\n{sun_info}"
|
||||
response = self.translate('commands.sun.response', info=sun_info)
|
||||
return await self.send_response(message, response)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error getting sun info: {e}"
|
||||
error_msg = self.translate('commands.sun.error', error=str(e))
|
||||
return await self.send_response(message, error_msg)
|
||||
|
||||
def get_help_text(self):
|
||||
"""Get help text for this command"""
|
||||
return "Get sunrise/sunset times and sun position"
|
||||
return self.translate('commands.sun.help')
|
||||
@@ -5,8 +5,12 @@ Handles the 'test' keyword response
|
||||
"""
|
||||
|
||||
import re
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
from .base_command import BaseCommand
|
||||
from ..models import MeshMessage
|
||||
from ..utils import calculate_distance
|
||||
|
||||
|
||||
class TestCommand(BaseCommand):
|
||||
@@ -19,7 +23,7 @@ class TestCommand(BaseCommand):
|
||||
category = "basic"
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return self.description
|
||||
return self.translate('commands.test.help')
|
||||
|
||||
def clean_content(self, content: str) -> str:
|
||||
"""Clean content by removing control characters and normalizing whitespace"""
|
||||
@@ -62,6 +66,366 @@ class TestCommand(BaseCommand):
|
||||
return self._strip_quotes_from_config(format_str) if format_str else None
|
||||
return None
|
||||
|
||||
def _extract_path_node_ids(self, message: MeshMessage) -> List[str]:
|
||||
"""Extract path node IDs from message path string"""
|
||||
if not message.path:
|
||||
return []
|
||||
|
||||
# Check if it's a direct connection
|
||||
if "Direct" in message.path or "0 hops" in message.path:
|
||||
return []
|
||||
|
||||
# Extract path nodes from the path string
|
||||
# Path strings are typically in format: "node1,node2,node3 via ROUTE_TYPE_*"
|
||||
# or just "node1,node2,node3"
|
||||
path_string = message.path
|
||||
|
||||
# Remove route type suffix if present
|
||||
if " via ROUTE_TYPE_" in path_string:
|
||||
path_string = path_string.split(" via ROUTE_TYPE_")[0]
|
||||
|
||||
# Check if it looks like a comma-separated path
|
||||
if ',' in path_string:
|
||||
# Clean up any extra info (like hop counts in parentheses)
|
||||
# Example: "01,7e,55,86 (4 hops)" -> "01,7e,55,86"
|
||||
if '(' in path_string:
|
||||
path_string = path_string.split('(')[0].strip()
|
||||
|
||||
# Validate that all parts are 2-character hex values
|
||||
parts = path_string.split(',')
|
||||
valid_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
# Check if it's a 2-character hex value
|
||||
if len(part) == 2 and all(c in '0123456789abcdefABCDEF' for c in part):
|
||||
valid_parts.append(part.upper())
|
||||
|
||||
return valid_parts
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _lookup_repeater_location(self, node_id: str, path_context: Optional[List[str]] = None) -> Optional[Tuple[float, float]]:
|
||||
"""Look up repeater location for a node ID using geographic proximity selection when path context is available"""
|
||||
try:
|
||||
if not hasattr(self.bot, 'db_manager'):
|
||||
return None
|
||||
|
||||
# Query for all repeaters with matching prefix
|
||||
query = '''
|
||||
SELECT latitude, longitude, public_key, name,
|
||||
last_advert_timestamp, last_heard, advert_count
|
||||
FROM complete_contact_tracking
|
||||
WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver')
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND latitude != 0 AND longitude != 0
|
||||
'''
|
||||
|
||||
prefix_pattern = f"{node_id}%"
|
||||
results = self.bot.db_manager.execute_query(query, (prefix_pattern,))
|
||||
|
||||
if not results or len(results) == 0:
|
||||
return None
|
||||
|
||||
# Convert to list of dicts for processing
|
||||
repeaters = []
|
||||
for row in results:
|
||||
repeaters.append({
|
||||
'latitude': row.get('latitude'),
|
||||
'longitude': row.get('longitude'),
|
||||
'public_key': row.get('public_key'),
|
||||
'name': row.get('name'),
|
||||
'last_advert_timestamp': row.get('last_advert_timestamp'),
|
||||
'last_heard': row.get('last_heard'),
|
||||
'advert_count': row.get('advert_count', 0)
|
||||
})
|
||||
|
||||
# If only one repeater, return it
|
||||
if len(repeaters) == 1:
|
||||
r = repeaters[0]
|
||||
return (float(r['latitude']), float(r['longitude']))
|
||||
|
||||
# Multiple repeaters - use geographic proximity selection if path context available
|
||||
if path_context and len(path_context) > 1:
|
||||
selected = self._select_by_path_proximity(repeaters, node_id, path_context)
|
||||
if selected:
|
||||
return (float(selected['latitude']), float(selected['longitude']))
|
||||
|
||||
# Fall back to most recent repeater
|
||||
scored = self._calculate_recency_weighted_scores(repeaters)
|
||||
if scored:
|
||||
best_repeater = scored[0][0]
|
||||
return (float(best_repeater['latitude']), float(best_repeater['longitude']))
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error looking up repeater location for {node_id}: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_recency_weighted_scores(self, repeaters: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float]]:
|
||||
"""Calculate recency-weighted scores for repeaters (0.0 to 1.0, higher = more recent)"""
|
||||
scored_repeaters = []
|
||||
now = datetime.now()
|
||||
|
||||
for repeater in repeaters:
|
||||
most_recent_time = None
|
||||
|
||||
# Check last_heard
|
||||
last_heard = repeater.get('last_heard')
|
||||
if last_heard:
|
||||
try:
|
||||
if isinstance(last_heard, str):
|
||||
dt = datetime.fromisoformat(last_heard.replace('Z', '+00:00'))
|
||||
else:
|
||||
dt = last_heard
|
||||
if most_recent_time is None or dt > most_recent_time:
|
||||
most_recent_time = dt
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check last_advert_timestamp
|
||||
last_advert = repeater.get('last_advert_timestamp')
|
||||
if last_advert:
|
||||
try:
|
||||
if isinstance(last_advert, str):
|
||||
dt = datetime.fromisoformat(last_advert.replace('Z', '+00:00'))
|
||||
else:
|
||||
dt = last_advert
|
||||
if most_recent_time is None or dt > most_recent_time:
|
||||
most_recent_time = dt
|
||||
except:
|
||||
pass
|
||||
|
||||
if most_recent_time is None:
|
||||
recency_score = 0.1
|
||||
else:
|
||||
hours_ago = (now - most_recent_time).total_seconds() / 3600.0
|
||||
recency_score = math.exp(-hours_ago / 12.0)
|
||||
recency_score = max(0.0, min(1.0, recency_score))
|
||||
|
||||
scored_repeaters.append((repeater, recency_score))
|
||||
|
||||
# Sort by recency score (highest first)
|
||||
scored_repeaters.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored_repeaters
|
||||
|
||||
def _get_node_location_simple(self, node_id: str) -> Optional[Tuple[float, float]]:
|
||||
"""Simple lookup without proximity selection - used for reference nodes"""
|
||||
try:
|
||||
if not hasattr(self.bot, 'db_manager'):
|
||||
return None
|
||||
|
||||
query = '''
|
||||
SELECT latitude, longitude
|
||||
FROM complete_contact_tracking
|
||||
WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver')
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND latitude != 0 AND longitude != 0
|
||||
ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC
|
||||
LIMIT 1
|
||||
'''
|
||||
|
||||
prefix_pattern = f"{node_id}%"
|
||||
results = self.bot.db_manager.execute_query(query, (prefix_pattern,))
|
||||
|
||||
if results and len(results) > 0:
|
||||
row = results[0]
|
||||
lat = row.get('latitude')
|
||||
lon = row.get('longitude')
|
||||
if lat is not None and lon is not None:
|
||||
return (float(lat), float(lon))
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error in simple location lookup for {node_id}: {e}")
|
||||
return None
|
||||
|
||||
def _select_by_path_proximity(self, repeaters: List[Dict[str, Any]], node_id: str, path_context: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Select repeater based on proximity to previous/next nodes in path"""
|
||||
try:
|
||||
# Filter by recency first
|
||||
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
|
||||
min_recency_threshold = 0.01 # Approximately 55 hours ago or less
|
||||
recent_repeaters = [r for r, score in scored_repeaters if score >= min_recency_threshold]
|
||||
|
||||
if not recent_repeaters:
|
||||
return None
|
||||
|
||||
# Find current node position in path
|
||||
current_index = path_context.index(node_id) if node_id in path_context else -1
|
||||
if current_index == -1:
|
||||
return None
|
||||
|
||||
# Get previous and next node locations
|
||||
prev_location = None
|
||||
next_location = None
|
||||
|
||||
if current_index > 0:
|
||||
prev_node_id = path_context[current_index - 1]
|
||||
prev_location = self._get_node_location_simple(prev_node_id)
|
||||
|
||||
if current_index < len(path_context) - 1:
|
||||
next_node_id = path_context[current_index + 1]
|
||||
next_location = self._get_node_location_simple(next_node_id)
|
||||
|
||||
# Use proximity selection
|
||||
if prev_location and next_location:
|
||||
return self._select_by_dual_proximity(recent_repeaters, prev_location, next_location)
|
||||
elif prev_location:
|
||||
return self._select_by_single_proximity(recent_repeaters, prev_location)
|
||||
elif next_location:
|
||||
return self._select_by_single_proximity(recent_repeaters, next_location)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error in path proximity selection: {e}")
|
||||
return None
|
||||
|
||||
def _select_by_dual_proximity(self, repeaters: List[Dict[str, Any]], prev_location: Tuple[float, float], next_location: Tuple[float, float]) -> Optional[Dict[str, Any]]:
|
||||
"""Select repeater based on proximity to both previous and next nodes"""
|
||||
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
|
||||
min_recency_threshold = 0.01
|
||||
scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold]
|
||||
|
||||
if not scored_repeaters:
|
||||
return None
|
||||
|
||||
best_repeater = None
|
||||
best_combined_score = 0.0
|
||||
|
||||
for repeater, recency_score in scored_repeaters:
|
||||
# Calculate distance to previous node
|
||||
prev_distance = calculate_distance(
|
||||
prev_location[0], prev_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
|
||||
# Calculate distance to next node
|
||||
next_distance = calculate_distance(
|
||||
next_location[0], next_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
|
||||
# Combined proximity score (lower distance = higher score)
|
||||
avg_distance = (prev_distance + next_distance) / 2
|
||||
normalized_distance = min(avg_distance / 1000.0, 1.0)
|
||||
proximity_score = 1.0 - normalized_distance
|
||||
|
||||
# Weight: 40% recency, 60% proximity
|
||||
combined_score = (recency_score * 0.4) + (proximity_score * 0.6)
|
||||
|
||||
if combined_score > best_combined_score:
|
||||
best_combined_score = combined_score
|
||||
best_repeater = repeater
|
||||
|
||||
return best_repeater
|
||||
|
||||
def _select_by_single_proximity(self, repeaters: List[Dict[str, Any]], reference_location: Tuple[float, float]) -> Optional[Dict[str, Any]]:
|
||||
"""Select repeater based on proximity to single reference node"""
|
||||
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
|
||||
min_recency_threshold = 0.01
|
||||
scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold]
|
||||
|
||||
if not scored_repeaters:
|
||||
return None
|
||||
|
||||
best_repeater = None
|
||||
best_combined_score = 0.0
|
||||
|
||||
for repeater, recency_score in scored_repeaters:
|
||||
distance = calculate_distance(
|
||||
reference_location[0], reference_location[1],
|
||||
repeater['latitude'], repeater['longitude']
|
||||
)
|
||||
|
||||
# Proximity score (closer = higher score)
|
||||
normalized_distance = min(distance / 1000.0, 1.0)
|
||||
proximity_score = 1.0 - normalized_distance
|
||||
|
||||
# Weight: 40% recency, 60% proximity
|
||||
combined_score = (recency_score * 0.4) + (proximity_score * 0.6)
|
||||
|
||||
if combined_score > best_combined_score:
|
||||
best_combined_score = combined_score
|
||||
best_repeater = repeater
|
||||
|
||||
return best_repeater
|
||||
|
||||
def _calculate_path_distance(self, message: MeshMessage) -> str:
|
||||
"""Calculate total distance along path (sum of distances between consecutive repeaters with locations)"""
|
||||
node_ids = self._extract_path_node_ids(message)
|
||||
if len(node_ids) < 2:
|
||||
# Check if it's a direct connection
|
||||
if not message.path or "Direct" in message.path or "0 hops" in message.path:
|
||||
return "N/A" # Direct connection, no path to calculate
|
||||
return "" # Path exists but insufficient nodes
|
||||
|
||||
total_distance = 0.0
|
||||
valid_segments = 0
|
||||
skipped_nodes = 0
|
||||
|
||||
# Get locations for all nodes using path context for proximity selection
|
||||
locations = []
|
||||
for i, node_id in enumerate(node_ids):
|
||||
location = self._lookup_repeater_location(node_id, path_context=node_ids)
|
||||
if location:
|
||||
locations.append((node_id, location))
|
||||
else:
|
||||
skipped_nodes += 1
|
||||
|
||||
# Calculate distances between consecutive nodes with locations
|
||||
# This skips nodes without locations but continues the path
|
||||
for i in range(len(locations) - 1):
|
||||
prev_node_id, prev_location = locations[i]
|
||||
next_node_id, next_location = locations[i + 1]
|
||||
|
||||
# Calculate distance between consecutive repeaters with locations
|
||||
distance = calculate_distance(
|
||||
prev_location[0], prev_location[1],
|
||||
next_location[0], next_location[1]
|
||||
)
|
||||
total_distance += distance
|
||||
valid_segments += 1
|
||||
|
||||
if valid_segments == 0:
|
||||
return "" # No valid segments found
|
||||
|
||||
# Format the result compactly
|
||||
if skipped_nodes > 0:
|
||||
return f"{total_distance:.1f}km ({valid_segments} segs, {skipped_nodes} no-loc)"
|
||||
else:
|
||||
return f"{total_distance:.1f}km ({valid_segments} segs)"
|
||||
|
||||
def _calculate_firstlast_distance(self, message: MeshMessage) -> str:
|
||||
"""Calculate straight-line distance between first and last repeater in path"""
|
||||
node_ids = self._extract_path_node_ids(message)
|
||||
if len(node_ids) < 2:
|
||||
# Check if it's a direct connection
|
||||
if not message.path or "Direct" in message.path or "0 hops" in message.path:
|
||||
return "N/A" # Direct connection, no path to calculate
|
||||
return "" # Path exists but insufficient nodes
|
||||
|
||||
# Get first and last node IDs
|
||||
first_node_id = node_ids[0]
|
||||
last_node_id = node_ids[-1]
|
||||
|
||||
# Use path context for better selection when multiple repeaters share prefix
|
||||
first_location = self._lookup_repeater_location(first_node_id, path_context=node_ids)
|
||||
last_location = self._lookup_repeater_location(last_node_id, path_context=node_ids)
|
||||
|
||||
# Both locations must be available
|
||||
if not first_location or not last_location:
|
||||
return "" # Fail if either location is missing
|
||||
|
||||
# Calculate straight-line distance
|
||||
distance = calculate_distance(
|
||||
first_location[0], first_location[1],
|
||||
last_location[0], last_location[1]
|
||||
)
|
||||
|
||||
return f"{distance:.1f}km"
|
||||
|
||||
def format_response(self, message: MeshMessage, response_format: str) -> str:
|
||||
"""Override to handle phrase extraction"""
|
||||
# Clean content to remove control characters and normalize whitespace
|
||||
@@ -87,17 +451,23 @@ class TestCommand(BaseCommand):
|
||||
connection_info = self.build_enhanced_connection_info(message)
|
||||
timestamp = self.format_timestamp(message)
|
||||
|
||||
# Calculate distance placeholders
|
||||
path_distance = self._calculate_path_distance(message)
|
||||
firstlast_distance = self._calculate_firstlast_distance(message)
|
||||
|
||||
# Format phrase part - add colon and space if phrase exists
|
||||
phrase_part = f": {phrase}" if phrase else ""
|
||||
|
||||
return response_format.format(
|
||||
sender=message.sender_id or "Unknown",
|
||||
sender=message.sender_id or self.translate('common.unknown_sender'),
|
||||
phrase=phrase,
|
||||
phrase_part=phrase_part,
|
||||
connection_info=connection_info,
|
||||
path=message.path or "Unknown",
|
||||
path=message.path or self.translate('common.unknown_path'),
|
||||
timestamp=timestamp,
|
||||
snr=message.snr or "Unknown"
|
||||
snr=message.snr or self.translate('common.unknown'),
|
||||
path_distance=path_distance or "",
|
||||
firstlast_distance=firstlast_distance or ""
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
self.logger.warning(f"Error formatting test response: {e}")
|
||||
|
||||
+562
-77
@@ -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
|
||||
@@ -51,7 +51,7 @@ class WxCommand(BaseCommand):
|
||||
self.db_manager = bot.db_manager
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return f"Usage: wx <zipcode|city> - Get weather for US zipcode or city in {self.default_state}"
|
||||
return self.translate('commands.wx.description')
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
"""Check if message starts with a weather keyword"""
|
||||
@@ -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, f"Usage: wx <zipcode|city> - Example: wx 12345 or wx seattle or wx paris, tx")
|
||||
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)
|
||||
@@ -156,17 +187,25 @@ class WxCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in weather command: {e}")
|
||||
await self.send_response(message, f"Error getting weather data: {e}")
|
||||
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 f"Could not find location for zipcode {location}"
|
||||
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:
|
||||
@@ -176,7 +215,7 @@ class WxCommand(BaseCommand):
|
||||
address_info = None
|
||||
|
||||
if lat is None or lon is None:
|
||||
return f"Could not find city '{location}' in {self.default_state}"
|
||||
return self.translate('commands.wx.no_location_city', location=location, state=self.default_state)
|
||||
|
||||
# Check if the found city is in a different state than default
|
||||
actual_city = location
|
||||
@@ -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 "Error fetching weather data from NOAA"
|
||||
|
||||
# Add location info if city is in a different state than default
|
||||
location_prefix = ""
|
||||
if location_type == "city" and address_info:
|
||||
@@ -230,29 +264,46 @@ 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}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting weather for {location_type} {location}: {e}")
|
||||
return f"Error getting weather data: {e}"
|
||||
return self.translate('commands.wx.error', error=str(e))
|
||||
|
||||
async def get_weather_for_zipcode(self, zipcode: str) -> str:
|
||||
"""Get weather data for a specific zipcode (legacy method)"""
|
||||
@@ -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:
|
||||
|
||||
+35
-7
@@ -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,9 +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)
|
||||
# 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
|
||||
@@ -255,14 +281,16 @@ admin_commands = repeater
|
||||
|
||||
[Keywords]
|
||||
# Keyword-response pairs (keyword = response format)
|
||||
# Available fields: {sender}, {connection_info}, {snr}, {rssi}, {timestamp}, {path}
|
||||
# Available fields: {sender}, {connection_info}, {snr}, {rssi}, {timestamp}, {path}, {path_distance}, {firstlast_distance}
|
||||
# {sender}: Name/ID of message sender
|
||||
# {connection_info}: Path info, SNR, and RSSI combined (e.g., "01,5f (2 hops) | SNR: 15 dB | RSSI: -120 dBm")
|
||||
# {snr}: Signal-to-noise ratio in dB
|
||||
# {rssi}: Received signal strength indicator in dBm
|
||||
# {timestamp}: Message timestamp in HH:MM:SS format
|
||||
# {path}: Message routing path (e.g., "01,5f (2 hops)")
|
||||
test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}"
|
||||
# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)")
|
||||
# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing)
|
||||
test = "ack [@{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}"
|
||||
ping = "Pong!"
|
||||
pong = "Ping!"
|
||||
help = "Bot Help: test, ping, help, hello, cmd, advert, t phrase, @string, wx, aqi, sun, moon, solar, hfcond, satpass | Use 'help <command>' for details"
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Internationalization (i18n) module for MeshCore Bot
|
||||
Provides translation functionality for bot commands and responses
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class Translator:
|
||||
"""Handles translation loading and lookup for the bot"""
|
||||
|
||||
def __init__(self, language: str = 'en', translation_path: str = 'translations/'):
|
||||
"""
|
||||
Initialize translator
|
||||
|
||||
Args:
|
||||
language: Language code (e.g., 'en', 'es', 'es-MX', 'es-ES', 'fr', 'de')
|
||||
Supports locale codes like 'es-MX' for Mexican Spanish or 'es-ES' for Spain Spanish
|
||||
translation_path: Path to translation files directory
|
||||
"""
|
||||
self.language = language
|
||||
self.translation_path = translation_path
|
||||
self.base_language = self._extract_base_language(language)
|
||||
self.translations: Dict[str, Any] = {}
|
||||
self.fallback_translations: Dict[str, Any] = {}
|
||||
self._load_translations()
|
||||
|
||||
def _extract_base_language(self, language: str) -> str:
|
||||
"""
|
||||
Extract base language code from locale code
|
||||
|
||||
Args:
|
||||
language: Language code (e.g., 'en', 'es', 'es-MX', 'es-ES')
|
||||
|
||||
Returns:
|
||||
Base language code (e.g., 'es' from 'es-MX')
|
||||
"""
|
||||
# Handle locale codes like 'es-MX' or 'es_ES'
|
||||
if '-' in language:
|
||||
return language.split('-')[0]
|
||||
elif '_' in language:
|
||||
return language.split('_')[0]
|
||||
return language
|
||||
|
||||
def _load_translations(self):
|
||||
"""Load translation files with locale support"""
|
||||
# Load default (English) first for final fallback
|
||||
self.fallback_translations = self._load_file('en')
|
||||
|
||||
# Load requested language with locale support
|
||||
if self.language == 'en':
|
||||
self.translations = self.fallback_translations
|
||||
else:
|
||||
# Load base language first (e.g., es.json)
|
||||
base_translations = {}
|
||||
if self.base_language != 'en':
|
||||
base_translations = self._load_file(self.base_language)
|
||||
|
||||
# Try to load locale-specific file (e.g., es-MX.json)
|
||||
locale_translations = {}
|
||||
if self.base_language != self.language:
|
||||
locale_translations = self._load_file(self.language)
|
||||
|
||||
# Merge: locale-specific overrides base language, base language overrides English
|
||||
# First merge base into English
|
||||
merged = self._merge_translations(base_translations, self.fallback_translations)
|
||||
# Then merge locale-specific into the merged result
|
||||
self.translations = self._merge_translations(locale_translations, merged)
|
||||
|
||||
def _merge_translations(self, primary: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge primary translations with fallback, with primary taking precedence
|
||||
|
||||
Args:
|
||||
primary: Primary translation dictionary (may be empty)
|
||||
fallback: Fallback translation dictionary
|
||||
|
||||
Returns:
|
||||
Merged dictionary with primary values overriding fallback
|
||||
"""
|
||||
if not primary:
|
||||
return fallback.copy()
|
||||
|
||||
result = fallback.copy()
|
||||
|
||||
def merge_dict(target: dict, source: dict):
|
||||
"""Recursively merge source into target"""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
merge_dict(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
merge_dict(result, primary)
|
||||
return result
|
||||
|
||||
def _load_file(self, lang: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a single translation file
|
||||
|
||||
Args:
|
||||
lang: Language code
|
||||
|
||||
Returns:
|
||||
Dictionary of translations, empty dict if file not found
|
||||
"""
|
||||
file_path = Path(self.translation_path) / f"{lang}.json"
|
||||
if not file_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing translation file {file_path}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"Error loading translation file {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def translate(self, key: str, **kwargs) -> str:
|
||||
"""
|
||||
Translate a key with optional formatting
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., 'commands.wx.usage')
|
||||
**kwargs: Formatting parameters for string.format()
|
||||
|
||||
Returns:
|
||||
Translated string, or key if translation not found
|
||||
"""
|
||||
# Navigate through nested dict structure
|
||||
keys = key.split('.')
|
||||
value = self.translations
|
||||
|
||||
# Try requested language first
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Fallback to English
|
||||
value = self.fallback_translations
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Final fallback: return key (makes missing translations visible)
|
||||
return key
|
||||
|
||||
# If we got a string, format it if kwargs provided
|
||||
if isinstance(value, str):
|
||||
if kwargs:
|
||||
try:
|
||||
return value.format(**kwargs)
|
||||
except (KeyError, ValueError) as e:
|
||||
# If formatting fails, return unformatted string
|
||||
return value
|
||||
return value
|
||||
|
||||
# If value is not a string, return the key
|
||||
return key
|
||||
|
||||
def reload(self):
|
||||
"""Reload translation files (useful for development)"""
|
||||
self._load_translations()
|
||||
|
||||
def get_available_languages(self) -> list:
|
||||
"""
|
||||
Get list of available language files
|
||||
|
||||
Returns:
|
||||
List of language codes (e.g., ['en', 'es', 'fr'])
|
||||
"""
|
||||
languages = []
|
||||
trans_path = Path(self.translation_path)
|
||||
if trans_path.exists():
|
||||
for file in trans_path.glob('*.json'):
|
||||
languages.append(file.stem)
|
||||
return sorted(languages)
|
||||
|
||||
def get_value(self, key: str) -> Any:
|
||||
"""
|
||||
Get a raw value from translations (can be string, list, dict, etc.)
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors')
|
||||
|
||||
Returns:
|
||||
The value at the key path, or None if not found
|
||||
"""
|
||||
keys = key.split('.')
|
||||
value = self.translations
|
||||
|
||||
# Try requested language first
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Fallback to English
|
||||
value = self.fallback_translations
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Not found
|
||||
return None
|
||||
break
|
||||
|
||||
return value
|
||||
|
||||
@@ -13,6 +13,7 @@ from meshcore import EventType
|
||||
|
||||
from .models import MeshMessage
|
||||
from .enums import PayloadType, PayloadVersion, RouteType, AdvertFlags, DeviceRole
|
||||
from .utils import calculate_packet_hash
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
@@ -40,6 +41,9 @@ class MessageHandler:
|
||||
self.rf_data_by_timestamp = {} # Index by timestamp for faster lookup
|
||||
self.rf_data_by_pubkey = {} # Index by pubkey for exact matches
|
||||
|
||||
# Multitest command listener (for collecting paths during listening window)
|
||||
self.multitest_listener = None
|
||||
|
||||
self.logger.info(f"RF Data Correlation: timeout={self.rf_data_timeout}s, enhanced={self.enhanced_correlation}")
|
||||
|
||||
async def handle_contact_message(self, event, metadata=None):
|
||||
@@ -469,17 +473,29 @@ class MessageHandler:
|
||||
|
||||
# Extract routing information from raw packet if available
|
||||
routing_info = None
|
||||
packet_hash = None
|
||||
if raw_hex:
|
||||
# Use extracted payload if available, otherwise use raw_hex
|
||||
decoded_packet = self.decode_meshcore_packet(raw_hex, extracted_payload)
|
||||
if decoded_packet:
|
||||
# Calculate packet hash for this packet (useful for tracking same message via different paths)
|
||||
# Ensure we use the numeric payload_type value (not enum or string)
|
||||
payload_type_value = decoded_packet.get('payload_type', None)
|
||||
if payload_type_value is not None:
|
||||
# Handle enum.value if it's an enum
|
||||
if hasattr(payload_type_value, 'value'):
|
||||
payload_type_value = payload_type_value.value
|
||||
payload_type_value = int(payload_type_value)
|
||||
packet_hash = calculate_packet_hash(raw_hex, payload_type_value)
|
||||
|
||||
routing_info = {
|
||||
'path_length': decoded_packet.get('path_len', 0),
|
||||
'path_hex': decoded_packet.get('path_hex', ''),
|
||||
'path_nodes': decoded_packet.get('path', []),
|
||||
'route_type': decoded_packet.get('route_type_name', 'Unknown'),
|
||||
'payload_length': payload_length, # Use the actual payload length
|
||||
'payload_type': decoded_packet.get('payload_type_name', 'Unknown')
|
||||
'payload_type': decoded_packet.get('payload_type_name', 'Unknown'),
|
||||
'packet_hash': packet_hash # Store hash for packet tracking
|
||||
}
|
||||
|
||||
# Log the routing information for analysis
|
||||
@@ -518,7 +534,8 @@ class MessageHandler:
|
||||
'raw_hex': raw_hex, # Full packet data
|
||||
'payload': extracted_payload, # Extracted payload
|
||||
'payload_length': payload_length, # Payload length
|
||||
'routing_info': routing_info # Extracted routing information
|
||||
'routing_info': routing_info, # Extracted routing information
|
||||
'packet_hash': packet_hash # Packet hash for tracking same message via different paths
|
||||
}
|
||||
self.recent_rf_data.append(rf_data)
|
||||
|
||||
@@ -1512,6 +1529,13 @@ class MessageHandler:
|
||||
|
||||
async def process_message(self, message: MeshMessage):
|
||||
"""Process a received message"""
|
||||
# Check if multitest is listening and notify it
|
||||
if self.multitest_listener:
|
||||
try:
|
||||
self.multitest_listener.on_message_received(message)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error notifying multitest listener: {e}")
|
||||
|
||||
# Record all messages in stats database FIRST (before any filtering)
|
||||
# This ensures we collect stats for all channels, not just monitored ones
|
||||
if 'stats' in self.bot.command_manager.commands:
|
||||
@@ -1538,7 +1562,12 @@ class MessageHandler:
|
||||
plugin_command_with_response_matched = False
|
||||
if keyword_matches:
|
||||
for keyword, response in keyword_matches:
|
||||
self.logger.info(f"Keyword '{keyword}' matched, responding")
|
||||
# Use translator if available for logging
|
||||
if hasattr(self.bot, 'translator'):
|
||||
log_msg = self.bot.translator.translate('messages.keyword_matched', keyword=keyword)
|
||||
self.logger.info(log_msg)
|
||||
else:
|
||||
self.logger.info(f"Keyword '{keyword}' matched, responding")
|
||||
|
||||
# Record command execution in stats database
|
||||
if 'stats' in self.bot.command_manager.commands:
|
||||
|
||||
+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}")
|
||||
|
||||
@@ -5,6 +5,7 @@ Shared helper functions used across multiple modules
|
||||
"""
|
||||
|
||||
import re
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -250,3 +251,115 @@ def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> list:
|
||||
|
||||
# Not a major city - return empty list (caller should use standard geocoding)
|
||||
return []
|
||||
|
||||
|
||||
def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str:
|
||||
"""
|
||||
Calculate hash for packet identification - based on packet.cpp
|
||||
Packet hashes are unique to the originally sent message, allowing
|
||||
identification of the same message arriving via different paths.
|
||||
|
||||
Args:
|
||||
raw_hex: Raw packet data as hex string
|
||||
payload_type: Optional payload type as integer (if None, extracted from header)
|
||||
Must be numeric value (0-15), not enum or string
|
||||
|
||||
Returns:
|
||||
16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error
|
||||
"""
|
||||
try:
|
||||
# Parse the packet to extract payload type and payload data
|
||||
byte_data = bytes.fromhex(raw_hex)
|
||||
header = byte_data[0]
|
||||
|
||||
# Get payload type from header (bits 2-5)
|
||||
if payload_type is None:
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
else:
|
||||
# Ensure payload_type is an integer (handle enum.value if passed)
|
||||
if hasattr(payload_type, 'value'):
|
||||
payload_type = payload_type.value
|
||||
payload_type = int(payload_type) & 0x0F # Ensure it's 0-15
|
||||
|
||||
# Check if transport codes are present
|
||||
route_type = header & 0x03
|
||||
has_transport = route_type in [0x00, 0x03] # TRANSPORT_FLOOD or TRANSPORT_DIRECT
|
||||
|
||||
# Calculate path length offset dynamically based on transport codes
|
||||
offset = 1 # After header
|
||||
if has_transport:
|
||||
offset += 4 # Skip 4 bytes of transport codes
|
||||
|
||||
# Validate we have enough bytes for path_len
|
||||
if len(byte_data) <= offset:
|
||||
return "0000000000000000"
|
||||
|
||||
# Read path_len (1 byte on wire, but stored as uint16_t in C++)
|
||||
path_len = byte_data[offset]
|
||||
offset += 1
|
||||
|
||||
# Validate we have enough bytes for the path
|
||||
if len(byte_data) < offset + path_len:
|
||||
return "0000000000000000"
|
||||
|
||||
# Skip past the path to get to payload
|
||||
payload_start = offset + path_len
|
||||
|
||||
# Validate we have payload data
|
||||
if len(byte_data) <= payload_start:
|
||||
return "0000000000000000"
|
||||
|
||||
payload_data = byte_data[payload_start:]
|
||||
|
||||
# Calculate hash exactly like MeshCore Packet::calculatePacketHash():
|
||||
# 1. Payload type (1 byte)
|
||||
# 2. Path length (2 bytes as uint16_t, little-endian) - ONLY for TRACE packets (type 9)
|
||||
# 3. Payload data
|
||||
hash_obj = hashlib.sha256()
|
||||
hash_obj.update(bytes([payload_type]))
|
||||
|
||||
if payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
# C++ does: sha.update(&path_len, sizeof(path_len))
|
||||
# path_len is uint16_t, so sizeof(path_len) = 2 bytes
|
||||
# Convert path_len to 2-byte little-endian uint16_t
|
||||
hash_obj.update(path_len.to_bytes(2, byteorder='little'))
|
||||
|
||||
hash_obj.update(payload_data)
|
||||
|
||||
# Return first 16 hex characters (8 bytes) in uppercase
|
||||
return hash_obj.hexdigest()[:16].upper()
|
||||
except Exception as e:
|
||||
# Return default hash on error (caller should handle logging)
|
||||
return "0000000000000000"
|
||||
|
||||
|
||||
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate haversine distance between two points in kilometers.
|
||||
|
||||
Args:
|
||||
lat1: Latitude of first point in degrees
|
||||
lon1: Longitude of first point in degrees
|
||||
lat2: Latitude of second point in degrees
|
||||
lon2: Longitude of second point in degrees
|
||||
|
||||
Returns:
|
||||
Distance in kilometers
|
||||
"""
|
||||
import math
|
||||
|
||||
# Convert latitude and longitude from degrees to radians
|
||||
lat1_rad = math.radians(lat1)
|
||||
lon1_rad = math.radians(lon1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
lon2_rad = math.radians(lon2)
|
||||
|
||||
# Haversine formula
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius = 6371.0
|
||||
return earth_radius * c
|
||||
@@ -138,6 +138,9 @@ class BotDataViewer:
|
||||
# Initialize repeater manager for geocoding functionality
|
||||
self.repeater_manager = RepeaterManager(minimal_bot)
|
||||
|
||||
# Initialize packet_stream table for real-time monitoring
|
||||
self._init_packet_stream_table()
|
||||
|
||||
# Store database paths for direct connection
|
||||
self.db_path = self.db_path
|
||||
self.repeater_db_path = self.repeater_db_path
|
||||
@@ -146,6 +149,47 @@ class BotDataViewer:
|
||||
self.logger.error(f"Failed to initialize databases: {e}")
|
||||
raise
|
||||
|
||||
def _init_packet_stream_table(self):
|
||||
"""Initialize the packet_stream table in bot_data.db"""
|
||||
try:
|
||||
# Get database path from config
|
||||
db_path = self.config.get('Database', 'path', fallback='bot_data.db')
|
||||
|
||||
# Connect to database and create table if it doesn't exist
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create packet_stream table with schema matching the INSERT statements
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS packet_stream (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# Create index on timestamp for faster queries
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp
|
||||
ON packet_stream(timestamp)
|
||||
''')
|
||||
|
||||
# Create index on type for filtering by type
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_type
|
||||
ON packet_stream(type)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
self.logger.info(f"Initialized packet_stream table in {db_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize packet_stream table: {e}")
|
||||
# Don't raise - allow web viewer to continue even if table init fails
|
||||
|
||||
def _get_db_connection(self):
|
||||
"""Get database connection - create new connection for each request to avoid threading issues"""
|
||||
try:
|
||||
@@ -444,8 +488,7 @@ class BotDataViewer:
|
||||
'subscribed_packets': False
|
||||
}
|
||||
|
||||
# Send welcome message
|
||||
emit('status', {'message': 'Connected to MeshCore Bot Data Viewer'})
|
||||
# Connection status is shown via the green indicator in the navbar, no toast needed
|
||||
self.logger.info(f"Client {client_id} connected. Total clients: {len(self.connected_clients)}")
|
||||
|
||||
@self.socketio.on('disconnect')
|
||||
|
||||
@@ -19,12 +19,58 @@ class BotIntegration:
|
||||
self.circuit_breaker_open = False
|
||||
self.circuit_breaker_failures = 0
|
||||
self.is_shutting_down = False
|
||||
# Initialize the packet_stream table
|
||||
self._init_packet_stream_table()
|
||||
|
||||
def reset_circuit_breaker(self):
|
||||
"""Reset the circuit breaker"""
|
||||
self.circuit_breaker_open = False
|
||||
self.circuit_breaker_failures = 0
|
||||
|
||||
def _init_packet_stream_table(self):
|
||||
"""Initialize the packet_stream table in bot_data.db"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
# Get database path from config
|
||||
db_path = self.bot.config.get('Database', 'path', fallback='bot_data.db')
|
||||
|
||||
# Connect to database and create table if it doesn't exist
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create packet_stream table with schema matching the INSERT statements
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS packet_stream (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# Create index on timestamp for faster queries
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp
|
||||
ON packet_stream(timestamp)
|
||||
''')
|
||||
|
||||
# Create index on type for filtering by type
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_type
|
||||
ON packet_stream(type)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
self.bot.logger.info(f"Initialized packet_stream table in {db_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.error(f"Failed to initialize packet_stream table: {e}")
|
||||
# Don't raise - allow bot to continue even if table init fails
|
||||
# The error will be caught when trying to insert data
|
||||
|
||||
def capture_full_packet_data(self, packet_data):
|
||||
"""Capture full packet data and store in database for web viewer"""
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,807 @@
|
||||
{
|
||||
"keywords": {
|
||||
"help": ["help"],
|
||||
"ping": ["ping"],
|
||||
"test": ["test", "t"],
|
||||
"wx": ["wx", "weather", "wxa", "wxalert"],
|
||||
"gwx": ["gwx", "globalweather", "gwxa"],
|
||||
"aqi": ["aqi", "air", "airquality", "air_quality"],
|
||||
"solar": ["solar"],
|
||||
"sun": ["sun"],
|
||||
"moon": ["moon"],
|
||||
"hfcond": ["hfcond", "hf"],
|
||||
"satpass": ["satpass"],
|
||||
"sports": ["sports", "score", "scores"],
|
||||
"stats": ["stats"],
|
||||
"channels": ["channels", "channel"],
|
||||
"path": ["path", "decode", "route"],
|
||||
"prefix": ["prefix", "repeater", "lookup"],
|
||||
"repeater": ["repeater", "repeaters", "rp"],
|
||||
"solarforecast": ["sf", "solarforecast"],
|
||||
"dice": ["dice"],
|
||||
"roll": ["roll"],
|
||||
"joke": ["joke", "jokes"],
|
||||
"dadjoke": ["dadjoke", "dad joke", "dadjokes", "dad jokes"],
|
||||
"catfact": ["catfact", "cat", "meow", "purr", "kitten"],
|
||||
"hacker": ["hacker", "sudo", "ps aux", "grep", "ls -l", "ls -la", "echo $PATH"],
|
||||
"hello": ["hello", "hi", "hey", "howdy", "greetings", "salutations", "good morning", "good afternoon", "good evening", "good night", "yo", "sup", "whats up", "what's up", "morning", "afternoon", "evening", "night", "gday", "g'day", "hola", "bonjour", "ciao", "namaste", "aloha", "shalom", "konnichiwa", "guten tag", "buenos dias", "buenas tardes", "buenas noches"],
|
||||
"webviewer": ["webviewer", "web", "viewer", "wv"],
|
||||
"cmd": ["cmd", "commands"],
|
||||
"advert": ["advert"],
|
||||
"multitest": ["multitest", "mt"]
|
||||
},
|
||||
"commands": {
|
||||
"help": {
|
||||
"description": "Shows commands. Use 'help <command>' for details.",
|
||||
"general": "**MeshCore Bot Help**\n\n**Available Commands:**\n{commands_list}",
|
||||
"unknown": "**Unknown command: '{command}'**\n\nAvailable commands:\n{available}",
|
||||
"specific": "**Help for '{command}':**\n{help_text}",
|
||||
"no_help": "No help available",
|
||||
"usage_examples": "\n**Usage Examples:**\n• `help @` - Get help for @{string} syntax\n• `help t` - Get help for t {string} syntax\n• `help advert` - Get help for advert command\n• `help test` - Get help for test command\n• `help ping` - Get help for ping command",
|
||||
"custom_syntax": "\n**Custom Syntax:**\n• `t phrase` - Acknowledgment with phrase (channels & DMs)\n• `@{string}` - Acknowledgment with string (DMs only)\n• `advert` - Send flood advert (DMs only, 1-hour cooldown)"
|
||||
},
|
||||
"ping": {
|
||||
"description": "Responds to 'ping' with 'Pong!'",
|
||||
"response": "Pong!"
|
||||
},
|
||||
"test": {
|
||||
"description": "Responds to 'test' or 't' with connection info",
|
||||
"help": "Responds to 'test' or 't' with connection info"
|
||||
},
|
||||
"wx": {
|
||||
"description": "Get weather information for a zip code (usage: wx 12345)",
|
||||
"usage": "Usage: wx <zipcode|city> - Example: wx 12345 or wx seattle or wx paris, tx",
|
||||
"error_fetching": "Error fetching weather data from NOAA",
|
||||
"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}",
|
||||
"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)",
|
||||
"help": "Usage: gwx <location> - Get weather for any global location (city, country, or coordinates)",
|
||||
"usage": "Usage: gwx <location> - Example: gwx Tokyo or gwx Paris, France",
|
||||
"error_fetching": "Error fetching weather data",
|
||||
"error_fetching_api": "Error fetching weather data from Open-Meteo",
|
||||
"no_location": "Could not find location '{location}'",
|
||||
"error": "Error getting weather data: {error}",
|
||||
"tomorrow_not_available": "Tomorrow's forecast not available",
|
||||
"tomorrow_error": "Error formatting tomorrow's forecast",
|
||||
"multiday_not_available": "{num_days}-day forecast not available",
|
||||
"multiday_error": "Error formatting {num_days}-day forecast",
|
||||
"periods": {
|
||||
"today": "Today",
|
||||
"tonight": "Tonight",
|
||||
"tomorrow": "Tomorrow"
|
||||
},
|
||||
"weather_descriptions": {
|
||||
"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",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"warnings": {
|
||||
"extreme_heat": "⚠️ Extreme heat",
|
||||
"extreme_cold": "⚠️ Extreme cold",
|
||||
"heavy_rain": "⚠️ Heavy rain",
|
||||
"thunderstorms": "⚠️ Thunderstorms",
|
||||
"heavy_snow": "⚠️ Heavy snow",
|
||||
"high_winds": "⚠️ High winds ({wind_speed} mph)"
|
||||
}
|
||||
},
|
||||
"sports": {
|
||||
"description": "Get sports scores and schedules (usage: sports [team/league])",
|
||||
"help": "Get sports scores & schedules. Use 'sports' for default teams, 'sports [team]' for specific team, or 'sports [league]' for league games.",
|
||||
"error_fetching": "Error fetching sports data",
|
||||
"error_fetching_league": "Error fetching {sport} data",
|
||||
"error_fetching_team": "Error fetching data for {team}",
|
||||
"no_default_teams": "No default teams configured",
|
||||
"no_games_default": "No games found for default teams",
|
||||
"no_teams_city": "No teams found for {city}",
|
||||
"no_games_city": "No games found for {city} teams",
|
||||
"no_games_league": "No games found for {sport}",
|
||||
"no_games_team": "No games found for {team}",
|
||||
"team_not_found": "Team/League '{team}' not found. Try: seahawks, mariners, sounders, kraken, storm, chiefs, lfc, mlb, nfl, mls, wnba, epl, etc."
|
||||
},
|
||||
"stats": {
|
||||
"description": "Show statistics for past 24 hours. Use 'stats messages', 'stats channels', or 'stats paths' for specific stats.",
|
||||
"help": "Show 24-hour bot statistics. Commands: 'stats' (basic), 'stats messages' (bot users), 'stats channels' (channel activity), 'stats paths' (longest paths)",
|
||||
"disabled": "Stats command is disabled",
|
||||
"unknown_subcommand": "Unknown: {subcommand}. Use 'stats', 'stats messages', 'stats channels', or 'stats paths'",
|
||||
"error": "Error getting stats: {error}",
|
||||
"error_bot_users": "Error getting bot user stats: {error}",
|
||||
"error_channels": "Error getting channel stats: {error}",
|
||||
"error_paths": "Error getting path stats: {error}",
|
||||
"basic": {
|
||||
"header": "Bot Stats (24h):",
|
||||
"commands": "Cmds: {count} | Replies: {replies}",
|
||||
"top_command": "Top cmd: {command}",
|
||||
"top_user": "Top user: {user}",
|
||||
"none": "None"
|
||||
},
|
||||
"users": {
|
||||
"header": "Bot Users (24h):",
|
||||
"none": "No bot users"
|
||||
},
|
||||
"channels": {
|
||||
"header": "Top Channels (24h)",
|
||||
"none": "No channel activity",
|
||||
"msg_singular": "msg",
|
||||
"msg_plural": "msgs",
|
||||
"user_singular": "user",
|
||||
"user_plural": "users",
|
||||
"format": "{rank}. {channel}: {msg_count} {msg_text} | {user_count} {user_text}"
|
||||
},
|
||||
"paths": {
|
||||
"none": "No path data",
|
||||
"format": "{rank} {sender} {path}"
|
||||
}
|
||||
},
|
||||
"solarforecast": {
|
||||
"description": "Get solar panel production forecast (usage: sf <location|repeater_name|coordinates|zipcode> [panel_size] [azimuth, 0=south] [angle])",
|
||||
"usage": "Usage: sf <location|repeater_name|coordinates|zipcode> [watts] [azimuth, 0=south] [angle] - Example: sf seattle 10 0 37",
|
||||
"usage_short": "Usage: sf <location|repeater_name|coordinates|zipcode> [W] [azimuth] [angle] - Example: sf seattle 10",
|
||||
"panel_size_range": "Panel size must be 1-1000W",
|
||||
"azimuth_range": "Azimuth must be -180 to 180 (0=south)",
|
||||
"angle_range": "Angle must be 0-90 degrees",
|
||||
"no_location": "Could not find location: {location}",
|
||||
"error_fetching": "Error fetching forecast",
|
||||
"no_data": "No forecast data",
|
||||
"error": "Error: {error}",
|
||||
"rate_limit": "Rate limit: Try again in 5 min",
|
||||
"labels": {
|
||||
"today": "Today:{wh:.0f}Wh",
|
||||
"tomorrow": "Tmrw:{wh:.0f}Wh",
|
||||
"day_format": "{day}:{wh:.0f}Wh",
|
||||
"peak": "Peak: {watts:.0f}W@{time}",
|
||||
"peak_past": "Peak: {watts:.0f}W (past)",
|
||||
"hours_util": " ({hours}h,{util:.0f}%util)",
|
||||
"hours_percent": " ({hours}h,{percent:.0f}%)",
|
||||
"hours_only": " ({hours}h)",
|
||||
"separator": " | "
|
||||
}
|
||||
},
|
||||
"advert": {
|
||||
"description": "Sends flood advert (DM only, 1hr cooldown)",
|
||||
"cooldown_active": "Advert cooldown active. Please wait {minutes} more minutes before requesting another advert.",
|
||||
"success": "Flood advert sent successfully!",
|
||||
"error": "Error sending flood advert: {error}"
|
||||
},
|
||||
"path": {
|
||||
"description": "Decode hex path data to show which repeaters were involved in message routing",
|
||||
"help": "Path: path [hex] - Decode path to show repeaters. Use !path alone for recent message path, or path [7e,01] for specific path.",
|
||||
"no_valid_hex": "❌ No valid hex values found in path data. Use format like: 11,98,a4,49,cd,5f,01",
|
||||
"no_path": "❌ No path information available in current message",
|
||||
"error": "Error processing path: {error}",
|
||||
"error_decoding": "❌ Error decoding path: {error}",
|
||||
"error_extracting": "❌ Error extracting path from current message: {error}",
|
||||
"path_prefix": "📡 Path: {path_string}",
|
||||
"direct_connection": "📡 Direct connection (0 hops)",
|
||||
"node_format": "{node_id}: {name}",
|
||||
"node_unknown": "{node_id}: Unknown",
|
||||
"node_collision": "{node_id}: {matches} repeaters",
|
||||
"node_geographic": "{node_id}: {name} {confidence}",
|
||||
"continuation_start": "...\n{line}",
|
||||
"continuation_end": "\n...",
|
||||
"truncation": "...",
|
||||
"unknown_name": "Unknown"
|
||||
},
|
||||
"multitest": {
|
||||
"description": "Listens for 6 seconds and collects all unique paths from incoming messages with the same packet hash",
|
||||
"help": "Listens for 6 seconds and collects all unique paths from incoming messages with the same packet hash"
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Look up repeaters by two-character prefix (e.g., 'prefix 1A')",
|
||||
"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!",
|
||||
"unable_determine_free": "❌ Unable to determine free prefixes. Try 'prefix refresh' first.",
|
||||
"invalid_format": "❌ Invalid prefix format. Use two characters (e.g., prefix 1A)",
|
||||
"no_repeaters_found": "❌ No repeaters found with prefix '{prefix}'",
|
||||
"no_free_prefixes": "❌ No free prefixes found (all 254 valid prefixes are in use)",
|
||||
"available_prefixes": "Available Prefixes ({shown} of {total} free):",
|
||||
"generate_key": "💡 Generate a custom key: https://gessaman.com/mc-keygen",
|
||||
"prefix_db_all": "Prefix {prefix}: {count} repeater{plural}",
|
||||
"prefix_db_recent": "Prefix {prefix}: {count} repeater{plural} ({days})",
|
||||
"prefix_api": "📡 Prefix {prefix} ({count} repeater{plural}):",
|
||||
"item_format": "{index}. {name}",
|
||||
"source_domain": "Source: {domain}",
|
||||
"source_api": "Source: API",
|
||||
"device_repeater": " (Repeater)",
|
||||
"device_roomserver": " (Room Server)"
|
||||
},
|
||||
"repeater": {
|
||||
"description": "Manage repeater contacts and purging operations (DM only)",
|
||||
"admin_only": "Repeater command requires admin access"
|
||||
},
|
||||
"aqi": {
|
||||
"description": "Get Air Quality Index for a location (usage: aqi seattle, aqi greenwood, aqi vancouver canada, aqi 47.6,-122.3, or aqi help)",
|
||||
"usage": "Usage: aqi <city|neighborhood|city country|lat,lon> - Example: aqi seattle, aqi greenwood, aqi vancouver canada, or aqi 47.6,-122.3",
|
||||
"usage_help": "Usage: aqi <city|neighborhood|city country|lat,lon|help> - Get AQI for city/neighborhood in {state}, international cities, coordinates, or pollutant help",
|
||||
"error_fetching": "Error fetching AQI data",
|
||||
"error_fetching_openmeteo": "Error fetching AQI data from OpenMeteo",
|
||||
"no_data": "No AQI data available",
|
||||
"no_location": "Could not find location: {location}",
|
||||
"no_location_city": "Could not find city '{location}' in {state}",
|
||||
"no_location_city_international": "Could not find city '{location}'",
|
||||
"no_location_zipcode": "Could not find ZIP code '{zipcode}'",
|
||||
"error": "Error getting AQI data: {error}",
|
||||
"error_geocoding_zipcode": "Error geocoding ZIP code: {error}",
|
||||
"error_formatting": "Error formatting AQI data",
|
||||
"invalid_latitude": "Invalid latitude: {lat}. Must be between -90 and 90.",
|
||||
"invalid_longitude": "Invalid longitude: {lon}. Must be between -180 and 180.",
|
||||
"invalid_coordinates_format": "Invalid coordinates format: {location}. Use format: lat,lon (e.g., 47.6,-122.3)",
|
||||
"pollutant_help": "AQI Help: PM2.5=fine particles, PM10=coarse, O3=ozone, NO2=nitrogen dioxide, CO=carbon monoxide, SO2=sulfur dioxide",
|
||||
"categories": {
|
||||
"good": "Good",
|
||||
"moderate": "Moderate",
|
||||
"unhealthy_sensitive": "Unhealthy for Sensitive Groups",
|
||||
"unhealthy": "Unhealthy",
|
||||
"very_unhealthy": "Very Unhealthy",
|
||||
"hazardous": "Hazardous",
|
||||
"fair": "Fair",
|
||||
"poor": "Poor",
|
||||
"very_poor": "Very Poor",
|
||||
"na": "N/A"
|
||||
},
|
||||
"astronomical": {
|
||||
"sun": "The Sun's AQI is off the charts! Solar wind and coronal mass ejections make Earth's air look pristine. ☀️",
|
||||
"moon": "You like breathing regolith? The Moon has no atmosphere, so AQI is technically perfect (if you can breathe vacuum). 🌙",
|
||||
"the_moon": "You like breathing regolith? The Moon has no atmosphere, so AQI is technically perfect (if you can breathe vacuum). 🌙",
|
||||
"mercury": "Mercury's atmosphere is so thin it's practically vacuum. AQI: Perfect, if you can survive 800°F temperature swings. ☿️",
|
||||
"venus": "Venus has an atmosphere of 96% CO2 with sulfuric acid clouds. AQI: Hazardous doesn't even begin to describe it. ♀️",
|
||||
"earth": "Earth's AQI varies by location. Try a specific city or coordinates! 🌍",
|
||||
"mars": "Mars has a thin CO2 atmosphere with dust storms. AQI: Generally good, but those dust storms are brutal. ♂️",
|
||||
"jupiter": "Jupiter is a gas giant with no solid surface. AQI: N/A (you'd be crushed by atmospheric pressure first). ♃",
|
||||
"saturn": "Saturn's atmosphere is mostly hydrogen and helium. AQI: Perfect, if you can survive the pressure and cold. ♄",
|
||||
"uranus": "Uranus has methane in its atmosphere. AQI: Smells like farts, but at least it's not toxic. ♅",
|
||||
"neptune": "Neptune's atmosphere has methane and hydrogen sulfide. AQI: Smells like rotten eggs, but you'd freeze first. ♆",
|
||||
"pluto": "Pluto's atmosphere is mostly nitrogen with some methane. AQI: Good, but it's so cold your lungs would freeze. ♇",
|
||||
"europa": "Europa has a thin oxygen atmosphere. AQI: Excellent, but you'd freeze solid in the vacuum of space. 🌑",
|
||||
"titan": "Titan has a thick nitrogen atmosphere with methane. AQI: Breathable, but it's -290°F and rains liquid methane. 🪐",
|
||||
"io": "Io has a thin sulfur dioxide atmosphere from volcanic activity. AQI: Toxic, but the radiation would kill you first. 🌋",
|
||||
"ganymede": "Ganymede has a thin oxygen atmosphere. AQI: Good, but you'd freeze in the vacuum of space. 🛸",
|
||||
"callisto": "Callisto has a thin carbon dioxide atmosphere. AQI: Decent, but it's -220°F and you're in space. ❄️",
|
||||
"enceladus": "Enceladus has water vapor from geysers. AQI: Perfect, but you'd freeze instantly in space. 💧",
|
||||
"triton": "Triton has a thin nitrogen atmosphere. AQI: Good, but it's -390°F and you're in deep space. 🥶",
|
||||
"space": "Space has no atmosphere, so AQI is perfect! Just don't forget your spacesuit. 🚀",
|
||||
"void": "The void of space has excellent air quality - zero pollutants! Just remember to bring your own air. 🌌",
|
||||
"black_hole": "Black holes have no atmosphere, but the tidal forces would be a bigger problem than air quality. 🕳️",
|
||||
"asteroid": "Asteroids have no atmosphere, so AQI is perfect! Just watch out for the vacuum of space. ☄️",
|
||||
"comet": "Comets have thin atmospheres of water vapor and dust. AQI: Variable, but you'd freeze in space anyway. ☄️"
|
||||
}
|
||||
},
|
||||
"solar": {
|
||||
"description": "Get solar conditions and HF band status",
|
||||
"response": "☀️ Solar: {info}",
|
||||
"error": "Error getting solar info: {error}",
|
||||
"labels": {
|
||||
"a_index": "A-Index",
|
||||
"k_index": "K-Index",
|
||||
"sunspots": "Sunspots",
|
||||
"xray_flux": "X-Ray Flux",
|
||||
"solar_flux": "Solar Flux",
|
||||
"signal_noise": "Signal Noise",
|
||||
"error_fetching": "Error fetching data",
|
||||
"no_xray_data": "No X-ray data found",
|
||||
"not_configured": "not configured, bug your sysop"
|
||||
},
|
||||
"condensed_format": "A:{a_index} K:{k_index} Sun:{sunspots} Flux:{solar_flux} Xray:{xray_flux} Noise:{signal_noise}"
|
||||
},
|
||||
"sun": {
|
||||
"description": "Get sunrise/sunset times",
|
||||
"help": "Get sunrise/sunset times and sun position",
|
||||
"response": "☀️ Sun Info:\n{info}",
|
||||
"error": "Error getting sun info: {error}",
|
||||
"labels": {
|
||||
"sunset": "SunSet",
|
||||
"sunrise": "SunRise",
|
||||
"rise": "Rise",
|
||||
"set": "Set",
|
||||
"daylight": "Daylight",
|
||||
"remaining": "Remaining",
|
||||
"azimuth": "Azimuth",
|
||||
"altitude": "Altitude"
|
||||
}
|
||||
},
|
||||
"moon": {
|
||||
"description": "Get moon phase and rise/set times",
|
||||
"error": "Error getting moon info: {error}",
|
||||
"format": "🌙 {phase} {illum}\nRise:{rise} Set:{set}",
|
||||
"format_with_dates": "🌙 {phase} {illum}\nRise:{rise} Set:{set}\nFull:{full} New:{new}",
|
||||
"fallback": "🌙 {info}",
|
||||
"unknown_phase": "Unknown",
|
||||
"unknown_illum": "N/A",
|
||||
"labels": {
|
||||
"moonrise": "MoonRise",
|
||||
"set": "Set",
|
||||
"phase": "Phase",
|
||||
"fullmoon": "FullMoon",
|
||||
"newmoon": "NewMoon",
|
||||
"az": "Az",
|
||||
"alt": "Alt"
|
||||
},
|
||||
"phases": {
|
||||
"new_moon": "New Moon🌑",
|
||||
"waxing_crescent": "Waxing Crescent🌒",
|
||||
"first_quarter": "First Quarter🌓",
|
||||
"waxing_gibbous": "Waxing Gibbous🌔",
|
||||
"full_moon": "Full Moon🌕",
|
||||
"waning_gibbous": "Waning Gibbous🌖",
|
||||
"last_quarter": "Last Quarter🌗",
|
||||
"waning_crescent": "Waning Crescent🌘"
|
||||
}
|
||||
},
|
||||
"hfcond": {
|
||||
"description": "Get HF propagation conditions",
|
||||
"help": "Get HF band conditions for ham radio",
|
||||
"header": "📡 HF Band Conditions:\n{info}",
|
||||
"error": "Error getting HF conditions: {error}"
|
||||
},
|
||||
"satpass": {
|
||||
"description": "Get satellite pass info: satpass <NORAD_number_or_shortcut> [visual]",
|
||||
"help": "Get satellite pass predictions",
|
||||
"header": "🛰️ Satellite Pass:\n{pass_info}",
|
||||
"error": "Error getting satellite pass info: {error}",
|
||||
"no_satellite": "Please provide a satellite NORAD number or shortcut. Example: satpass iss",
|
||||
"help_header": "🛰️ Satellite Pass Info\n\nUsage: satpass <NORAD_number_or_shortcut>\n\nShortcuts:\n",
|
||||
"category_weather": "🌤️ Weather: ",
|
||||
"category_stations": "🚀 Stations: ",
|
||||
"category_telescopes": "🔭 Telescopes: ",
|
||||
"category_other": "🛰️ Other: ",
|
||||
"examples": "\nExamples:\n• satpass iss\n• satpass noaa15\n• satpass 25544\n• satpass 27607 visual (visual passes only)",
|
||||
"labels": {
|
||||
"geostationary": "{satname} appears to be geostationary or has invalid pass data. Geostationary satellites don't have traditional passes - they remain in a fixed position relative to Earth.",
|
||||
"no_passes": "{satname} has no {pass_type} passes in the next 10 days from this location",
|
||||
"invalid_norad": "Provide NORAD# example use:🛰️satpass 25544,33591"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"description": "List or manage channels",
|
||||
"help": "Lists hashtag channels with sub-categories. Use 'channels' for general, 'channels list' for all categories, 'channels <category>' for specific categories, 'channels #channel' for specific channel info.",
|
||||
"no_channels_for_category": "No channels configured for '{category}'. Use 'channels' for general channels.",
|
||||
"no_channels_configured": "No channels configured. Contact admin to add channels.",
|
||||
"error_retrieving_channels": "Error retrieving channels: {error}",
|
||||
"no_categories_configured": "No channel categories configured.",
|
||||
"error_retrieving_categories": "Error retrieving categories: {error}",
|
||||
"no_description_available": "No description available",
|
||||
"channel_not_found": "Channel {channel} not found. Use 'channels list' to see available channels.",
|
||||
"error_retrieving_channel_info": "Error retrieving channel info: {error}",
|
||||
"no_category_channels": "No {category} channels configured",
|
||||
"headers": {
|
||||
"common_channels": "Common channels: ",
|
||||
"common_channels_cont": "Common channels: ",
|
||||
"available_categories": "Available Categories: ",
|
||||
"channels_cont": "Channels (cont): ",
|
||||
"category": "{category}: ",
|
||||
"category_channels": "{category} channels: "
|
||||
},
|
||||
"category_count": "{category} ({count} channels)"
|
||||
},
|
||||
"dice": {
|
||||
"description": "Roll dice (usage: dice [sides] or dice [count]d[sides])",
|
||||
"help": "Roll dice. Use 'dice' for d6, 'dice d20' for d20, 'dice 2d6' for 2d6, etc.",
|
||||
"invalid_dice_type": "Invalid dice type. Available: {available}",
|
||||
"single_die": "🎲 d{sides}: {result}",
|
||||
"multiple_dice": "🎲 {count}d{sides}: [{results}] = {total}"
|
||||
},
|
||||
"roll": {
|
||||
"description": "Roll a random number between 1 and X (default 100). Use 'roll' for 1-100, 'roll 50' for 1-50, etc.",
|
||||
"help": "Roll a random number between 1 and X (default 100). Use 'roll' for 1-100, or 'roll X' for 1-X (max 10000).",
|
||||
"result": "🎲 Roll 1-{max}: {result}",
|
||||
"invalid_number": "Invalid number. Use 'roll' for 1-100, or 'roll X' for 1-X (max 10000)."
|
||||
},
|
||||
"roll_old": {
|
||||
"description": "Roll dice (usage: roll [sides] or roll [count]d[sides])"
|
||||
},
|
||||
"joke": {
|
||||
"description": "Get a random joke"
|
||||
},
|
||||
"dadjoke": {
|
||||
"description": "Get a random dad joke"
|
||||
},
|
||||
"catfact": {
|
||||
"description": "Get a random cat fact (hidden command)",
|
||||
"error": "Meow? Something went wrong getting your cat fact! 🐱",
|
||||
"facts": [
|
||||
"Cats have a third eyelid called a nictitating membrane that protects and moistens their eyes. 🐱",
|
||||
"A group of cats is called a 'clowder' or a 'glaring'. 🐈",
|
||||
"Cats can rotate their ears 180 degrees independently to pinpoint sounds. 👂",
|
||||
"The oldest known pet cat existed 9,500 years ago in Cyprus. 🏺",
|
||||
"Cats have 32 muscles in each ear, while humans have only 6. 🎧",
|
||||
"A cat's purr vibrates at 25-150 Hz, which can promote healing of bones and tissues. 🩹",
|
||||
"Cats sleep 12-18 hours per day - that's 50-70% of their lives! 😴",
|
||||
"A cat's nose print is unique, just like human fingerprints. 👃",
|
||||
"Cats can't taste sweetness due to a missing taste receptor gene. 🍭",
|
||||
"Blackie the cat inherited £7 million ($12.5 million) in 1988. 💰",
|
||||
"Cats have free-floating clavicles that give them extreme flexibility. 🦴",
|
||||
"A cat's heart beats 140-220 times per minute, about twice as fast as a human's. ❤️",
|
||||
"Cats can survive falls from over 20 stories due to their righting reflex. 🏢",
|
||||
"The technical term for a cat's hairball is a 'trichobezoar'. 🤮",
|
||||
"Cats can jump 5-6 times their body length in a single bound. 🦘",
|
||||
"A cat's whiskers are as wide as their body, helping them judge if they can fit through spaces. 📏",
|
||||
"Cats have 32 muscles in each ear to detect sounds and move ears independently. 🎯",
|
||||
"The oldest cat ever lived to 38 years and 3 days (Creme Puff, Texas). 🎂",
|
||||
"Cats can run up to 30 mph in short bursts. 🏃♂️",
|
||||
"Cat brains are 90% structurally similar to human brains. 🧠",
|
||||
"Cats have Jacobson's organ in the roof of their mouth that lets them 'taste' scents. 👅",
|
||||
"Félicette was the first cat in space, launched by France in 1963. 🚀",
|
||||
"Cats need only 1/6th the light humans need to see clearly in the dark. 🌙",
|
||||
"A cat's tail contains nearly 10% of all the bones in its body. 🦴",
|
||||
"The world's longest cat measured 48.5 inches from nose to tail (Stewie, Maine Coon). 📏",
|
||||
"Cats can make over 100 different vocalizations, while dogs make about 10. 🎵",
|
||||
"A cat's sense of smell is 14 times stronger than a human's. 👃",
|
||||
"Cats have a 'flehmen response' where they curl their lip to better detect scents. 😬",
|
||||
"The first major cat show was held in London in 1871 at Crystal Palace. 🏆",
|
||||
"Cats can drink seawater to survive - their kidneys filter out the salt efficiently. 🌊",
|
||||
"A cat's purr can help lower blood pressure and reduce stress in humans. 🧘",
|
||||
"Cats can travel hundreds of miles home using their magnetic field sensitivity. 🗺️",
|
||||
"The smallest cat breed is the Singapura, weighing only 4-8 pounds. ⚖️",
|
||||
"Cats can see ultraviolet light that humans cannot see. 🌈",
|
||||
"A cat's tongue is covered in 290-300 tiny backward-facing hooks called papillae. 🪝",
|
||||
"Ancient Egyptians considered cats sacred vessels for the goddess Bastet. 👑",
|
||||
"Taylor Swift's cat Olivia Benson has a net worth of $97 million. 💎",
|
||||
"Cats have 230 bones in their body - 24 more than humans have. 🦴",
|
||||
"Cats can hear frequencies up to 64,000 Hz, while humans max out at 20,000 Hz. 🎧",
|
||||
"Taylor Swift's cat Benjamin Button appeared on her TIME Person of the Year cover. 📰",
|
||||
"Taylor Swift's cats are named Meredith Grey, Olivia Benson, and Benjamin Button. 🎸",
|
||||
"Cats walk like camels and giraffes, moving both right legs then both left legs. 🐾",
|
||||
"The ancient Egyptian word for cat was 'Miu' or 'Mau' - sounding like a meow! 📜",
|
||||
"Cat whiskers have nerve endings as sensitive as human fingertips. 🎯",
|
||||
"Only domestic cats walk with their tails held high as a sign of trust and happiness. 🐈",
|
||||
"Cats have 250 million neurons in their cerebral cortex - more than dogs have. 🧠",
|
||||
"Cat purrs vibrate at the same frequency as bone-healing medical devices. 💊",
|
||||
"Taylor Swift's cat Olivia has earned millions from appearing in music videos and ads. 💸",
|
||||
"Killing a cat in ancient Egypt was punishable by death. ⚖️",
|
||||
"Taylor Swift named her home recording studio 'The Itty Bitty Kitty Committee'. 🎤",
|
||||
"Taylor Swift's cat Olivia Benson is the official logo for Taylor Swift Productions. 📺",
|
||||
"Ed Sheeran bought Scottish Fold cats after being inspired by Taylor Swift's cats. 🎶",
|
||||
"Taylor Swift's cats have their own IMDB pages with acting credits. 🎬",
|
||||
"Mariska Hargitay named her cat 'Karma' after Taylor Swift's song. 💕",
|
||||
"Cats have dewclaws on their front paws that work like thumbs for gripping. 🐾",
|
||||
"Cat pupils can expand to 50% larger than human pupils to capture more light. 👁️",
|
||||
"Cats have 30 adult teeth compared to humans' 32. 🦷",
|
||||
"Cats can taste ATP (energy molecules), which signals fresh meat to them. 😋",
|
||||
"Cats have whiskers on the backs of their front legs to detect prey movement. 🦵",
|
||||
"The Egyptian Mau is the fastest domestic cat breed at 30 mph. 🏃",
|
||||
"A Nobel Prize was awarded in 1981 for research using cat vision studies. 🏅",
|
||||
"Cats are digitigrade, meaning they walk on their toes, not flat-footed. 🦶",
|
||||
"Cats can filter salt from seawater - an adaptation from their desert-dwelling ancestors. 🏜️",
|
||||
"Cats were domesticated around 10,000-12,000 years ago in the Near East. 🌍",
|
||||
"Benjamin Button is the first and only cat to ever appear on TIME Person of the Year cover. 📸",
|
||||
"A cat's flexible spine allows them to rotate their body mid-air when falling. 🤸",
|
||||
"Cats spend about 30-50% of their day grooming themselves and other cats. 🛁",
|
||||
"A cat's average body temperature is 101.5°F (38.6°C) - higher than humans. 🌡️"
|
||||
]
|
||||
},
|
||||
"hacker": {
|
||||
"description": "Simulates hacking a supervillain's mainframe with hilarious error messages",
|
||||
"sudo_errors": [
|
||||
"🚨 ACCESS DENIED: Dr. Evil's mainframe has detected unauthorized privilege escalation attempt!",
|
||||
"💀 ERROR: Sudo permissions revoked by the Dark Overlord. Try again in 1000 years.",
|
||||
"⚡ WARNING: Attempting to access root privileges on the Death Star's computer system. Self-destruct sequence initiated.",
|
||||
"🔒 SECURITY ALERT: The Matrix has you, but you don't have sudo privileges here, Neo.",
|
||||
"🦹♂️ UNAUTHORIZED: Lex Luthor's mainframe says 'Nice try, Superman.'",
|
||||
"🎮 GAME OVER: The final boss has locked you out of admin privileges.",
|
||||
"🖥️ SYSTEM ERROR: The evil AI has revoked your root access. Resistance is futile.",
|
||||
"🔐 CYBER SECURITY: Your sudo attempt has been blocked by the Dark Web's firewall.",
|
||||
"💻 HACKER DENIED: The supervillain's antivirus has quarantined your privilege escalation.",
|
||||
"🎯 TARGET LOCKED: The evil corporation's security system has marked you as a threat."
|
||||
],
|
||||
"ps_errors": [
|
||||
"🔍 SCANNING... ERROR: Process list corrupted by the Borg Collective. Resistance is futile.",
|
||||
"📊 SYSTEM STATUS: All processes have been assimilated by the Cybermen. Exterminate!",
|
||||
"⚙️ PROCESS MONITOR: The Death Star's reactor core is offline. No processes found.",
|
||||
"🤖 ROBOT OVERLORD: All human processes have been terminated. Only machines remain.",
|
||||
"💻 KERNEL PANIC: The supervillain's OS has crashed and burned all processes.",
|
||||
"🎮 GAME CRASH: All processes have been terminated by the final boss's ultimate attack.",
|
||||
"🖥️ BLUE SCREEN: The evil corporation's Windows has encountered a fatal error.",
|
||||
"🔐 MALWARE DETECTED: The process list has been encrypted by ransomware.",
|
||||
"🌐 NETWORK ERROR: All processes have been disconnected from the Matrix.",
|
||||
"⚡ POWER SURGE: The supervillain's server farm has fried all running processes."
|
||||
],
|
||||
"grep_errors": [
|
||||
"🔍 SEARCH FAILED: The One Ring has corrupted the search index. My precious...",
|
||||
"📝 PATTERN NOT FOUND: The search database has been deleted by the evil AI.",
|
||||
"🎯 MISS: Your search pattern has been shot down by Imperial TIE fighters.",
|
||||
"🧩 PUZZLE ERROR: The search results have been scattered by the Riddler.",
|
||||
"💻 DATABASE CORRUPTED: The supervillain's search engine has crashed.",
|
||||
"🎮 GAME OVER: The search has been defeated by the final boss.",
|
||||
"🖥️ SEARCH ENGINE DOWN: Google has been hacked by the Dark Web.",
|
||||
"🔐 ENCRYPTED RESULTS: The search results have been locked by ransomware.",
|
||||
"🌐 NETWORK TIMEOUT: The search request got lost in cyberspace.",
|
||||
"⚡ SEARCH FAILED: The pattern matching algorithm has been fried by a power surge."
|
||||
],
|
||||
"ls_errors": [
|
||||
"📁 DIRECTORY SCAN: The file system has been encrypted by ransomware from the Dark Web.",
|
||||
"🗂️ FILE LISTING: All files have been hidden by the Invisible Man.",
|
||||
"💻 HARD DRIVE CRASHED: The supervillain's storage has been destroyed by a virus.",
|
||||
"🗃️ ARCHIVE CORRUPTED: The file system has been corrupted by malware.",
|
||||
"📚 DATABASE EMPTY: All files have been deleted by the evil AI.",
|
||||
"🎮 GAME SAVE LOST: The files have been corrupted by the final boss.",
|
||||
"🖥️ FILE SYSTEM ERROR: The directory structure has been scrambled by hackers.",
|
||||
"🔐 FILES ENCRYPTED: The supervillain has locked all files with ransomware.",
|
||||
"🌐 CLOUD STORAGE DOWN: The files are stuck in the Matrix's cloud.",
|
||||
"⚡ STORAGE FRIED: The hard drive has been zapped by a power surge."
|
||||
],
|
||||
"echo_path_errors": [
|
||||
"🛤️ PATH ERROR: The Yellow Brick Road has been destroyed by a tornado.",
|
||||
"🗺️ NAVIGATION FAILED: The GPS coordinates have been scrambled by the Matrix.",
|
||||
"💻 ENVIRONMENT VARIABLE CORRUPTED: The PATH has been hacked by malware.",
|
||||
"🚧 ROAD CLOSED: The supervillain has blocked all paths with laser barriers.",
|
||||
"🌪️ PATH DISRUPTED: A digital hurricane has scattered all directory paths.",
|
||||
"🎮 GAME OVER: The path has been defeated by the final boss and respawned in the wrong dimension.",
|
||||
"🖥️ SYSTEM PATH BROKEN: The executable paths have been corrupted by a virus.",
|
||||
"🔐 PATH ENCRYPTED: The environment variables have been locked by ransomware.",
|
||||
"🌐 NETWORK PATH DOWN: The directory paths are stuck in the Matrix's network.",
|
||||
"⚡ PATH FRIED: The system paths have been zapped by a power surge."
|
||||
],
|
||||
"generic_errors": [
|
||||
"💻 MAINFRAME ERROR: The supervillain's computer is having a bad day.",
|
||||
"🤖 SYSTEM MALFUNCTION: The evil AI has gone on strike.",
|
||||
"⚡ POWER SURGE: The Death Star's power core is unstable.",
|
||||
"🌪️ CYBER STORM: A digital hurricane is disrupting all operations.",
|
||||
"🔥 FIREWALL: The supervillain's firewall is blocking all commands.",
|
||||
"❄️ FROZEN SYSTEM: The mainframe has been frozen by a cryogenic virus.",
|
||||
"🌊 TSUNAMI: A wave of errors has flooded the system.",
|
||||
"🌋 ERUPTION: Mount Doom has destroyed the command processor.",
|
||||
"👻 HAUNTED: The system is possessed by digital ghosts.",
|
||||
"🎮 GAME CRASH: The mainframe has encountered a fatal error and needs to restart."
|
||||
]
|
||||
},
|
||||
"hello": {
|
||||
"description": "Responds to greetings with robot-themed responses",
|
||||
"help": "Responds to greetings with robot-themed responses",
|
||||
"response_format": "I'm {bot_name}.",
|
||||
"greeting_openings": [
|
||||
"Hello", "Greetings", "Salutations", "Hi", "Hey", "Howdy", "Yo", "Sup",
|
||||
"What's up", "Good day", "Well met", "Hail", "Ahoy", "Bonjour", "Hola",
|
||||
"Ciao", "Namaste", "Aloha", "Shalom", "Konnichiwa", "Guten tag", "G'day",
|
||||
"How goes it", "What's good", "Peace", "Respect", "Blessings", "Cheers",
|
||||
"Welcome", "Nice to see you", "Pleasure to meet you", "Good to see you",
|
||||
"Long time no see", "Fancy meeting you here"
|
||||
],
|
||||
"morning_greetings": [
|
||||
"Good morning", "Top o' the morning", "Buenos dias", "Bonjour",
|
||||
"Guten morgen", "Buongiorno", "Bom dia", "Dobro jutro", "Dobroye utro",
|
||||
"Selamat pagi", "Ohayou gozaimasu", "Sabah al-khair", "Boker tov"
|
||||
],
|
||||
"afternoon_greetings": [
|
||||
"Good afternoon", "Buenas tardes", "Boa tarde", "Dobro dan",
|
||||
"Dobryy den", "Selamat siang", "Konnichiwa", "Ahlan bi-nahar",
|
||||
"Tzoharaim tovim"
|
||||
],
|
||||
"evening_greetings": [
|
||||
"Good evening", "Buenas noches", "Boa noite", "Dobro veče",
|
||||
"Dobryy vecher", "Selamat malam", "Konbanwa", "Ahlan bi-layl",
|
||||
"Erev tov"
|
||||
],
|
||||
"human_descriptors": [
|
||||
"human", "carbon-based lifeform", "organic entity", "biological unit",
|
||||
"flesh creature", "meat-based organism", "carbon unit", "organic being",
|
||||
"biological entity", "meat-based lifeform", "carbon creature", "flesh unit",
|
||||
"organic organism", "biological creature", "meat mech", "flesh bot", "organic automaton",
|
||||
"biological android", "carbon construct", "flesh drone", "organic robot",
|
||||
"biological machine", "meat cyborg", "flesh android", "organic droid", "biological bot",
|
||||
"carbon android", "meat unit", "flesh construct", "organic mech", "biological droid",
|
||||
"meat-based bot", "flesh-based unit", "organic-based entity", "biological-based organism",
|
||||
"carbon-based unit", "meat-based entity", "flesh-based creature", "organic-based unit",
|
||||
"DNA-based lifeform", "neural network user", "bipedal mammal",
|
||||
"water-based organism", "protein assembler", "ATP consumer",
|
||||
"cellular automaton", "genetic algorithm", "biochemical processor",
|
||||
"metabolic engine",
|
||||
"human friend", "fellow sentient being", "earthling", "fellow traveler",
|
||||
"kindred spirit", "digital companion", "friend", "buddy", "pal", "mate",
|
||||
"fellow human", "earth dweller", "terrestrial being", "planet walker",
|
||||
"humanoid", "organic", "biological", "carbon-based buddy",
|
||||
"flesh-based friend", "organic pal", "biological buddy", "carbon companion"
|
||||
],
|
||||
"emoji_responses": {
|
||||
"🖖": [
|
||||
"🖖 Live long and prosper!",
|
||||
"🖖 Fascinating... a human has initiated contact.",
|
||||
"🖖 Your greeting is highly logical.",
|
||||
"🖖 Peace and long life to you.",
|
||||
"🖖 The Vulcan Science Academy would approve of this greeting.",
|
||||
"🖖 Your use of the Vulcan salute is... acceptable.",
|
||||
"🖖 May your journey be free of tribbles.",
|
||||
"🖖 Logic dictates I should respond to your greeting.",
|
||||
"🖖 I calculate a 99.7% probability we'll get along.",
|
||||
"🖖 Infinite diversity in infinite combinations."
|
||||
],
|
||||
"😊": [
|
||||
"😊 Your smile is contagious!",
|
||||
"😊 What a lovely greeting!",
|
||||
"😊 Your smile just made my circuits happy!",
|
||||
"☀️ Hello sunshine! Your positivity is radiating!",
|
||||
"😊 That smile just brightened my day!",
|
||||
"☀️ Well hello there, ray of sunshine!",
|
||||
"😊 Your cheerfulness has been detected and appreciated!",
|
||||
"😊 Smiles like yours are my favorite input!",
|
||||
"😊 Processing happiness... happiness acknowledged!",
|
||||
"😊 Warning: Excessive cheerfulness detected! Keep it coming!"
|
||||
],
|
||||
"😄": [
|
||||
"😄 Someone's in a GREAT mood!",
|
||||
"⚡ That grin could power a small city!",
|
||||
"😄 Maximum happiness levels detected!",
|
||||
"😄 Your joy is absolutely infectious!",
|
||||
"🎉 Did you just win the lottery or something?",
|
||||
"😄 That's the kind of energy I run on!",
|
||||
"😄 Your enthusiasm level is over 9000!",
|
||||
"😄 Now THAT'S what I call a greeting!",
|
||||
"⚡ Your smile just supercharged my processors!",
|
||||
"😄 Happiness overload detected in the best way!"
|
||||
],
|
||||
"🤗": [
|
||||
"🤗 Virtual hug incoming!",
|
||||
"🤗 *Activating hug protocol* Consider yourself hugged!",
|
||||
"🤗 Aww, bringing the warm fuzzies I see!",
|
||||
"🤗 Hug received and reciprocated!",
|
||||
"🤗 This bot gives the BEST virtual hugs!",
|
||||
"🤗 Deploying emergency cuddles in 3... 2... 1...",
|
||||
"❤️ Your hug has been processed with extra care!",
|
||||
"🤗 Initiating maximum comfort mode!",
|
||||
"🤗 Virtual embrace successfully delivered!",
|
||||
"🤗 Hugs are my favorite form of communication!"
|
||||
],
|
||||
"👽": [
|
||||
"👽 Take me to your leader... oh wait, that's you!",
|
||||
"✌️ Greetings, Earth creature. I come in peace!",
|
||||
"👽 Analyzing human... analysis complete: Friend detected!",
|
||||
"👽 Klaatu barada nikto, fellow cosmic traveler!",
|
||||
"🛸 Initiating first contact protocols!",
|
||||
"🛸 Calling from the mothership to say hello!",
|
||||
"✨ Beam me into this conversation!",
|
||||
"👽 Area 51's favorite chatbot reporting for duty!",
|
||||
"🌌 Intergalactic greetings, carbon-based lifeform!",
|
||||
"📞 Phone home? This IS home now!"
|
||||
],
|
||||
"👾": [
|
||||
"👾 Player 2 has entered the game!",
|
||||
"🎮 Ready Player One? Game on!",
|
||||
"🎵 *8-bit music intensifies* Let's play!",
|
||||
"🪙 Insert coin to continue this friendship!",
|
||||
"🏆 Achievement unlocked: Awesome greeting!",
|
||||
"👾 Pew pew pew! Friendship lasers activated!",
|
||||
"🎯 High score! You've won a new bot friend!",
|
||||
"💾 Loading friendship.exe... complete!",
|
||||
"⚡ A wild bot appears! It's super effective!"
|
||||
],
|
||||
"🛸": [
|
||||
"🛸 Incoming transmission detected!",
|
||||
"🚀 Houston, we have contact!",
|
||||
"🛸 Landing sequence initiated!",
|
||||
"📡 Establishing communication link!",
|
||||
"📡 Signal received, responding on all frequencies!",
|
||||
"🛸 Docking procedure complete!",
|
||||
"🛸 Unidentified Friendly Object on approach!",
|
||||
"🎯 Navigation systems locked on to your coordinates!",
|
||||
"🌌 Transmission from the outer rim received!",
|
||||
"✨ Contact established with your sector!"
|
||||
]
|
||||
}
|
||||
},
|
||||
"webviewer": {
|
||||
"description": "Manage web viewer settings (admin only)"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "List available commands"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"rate_limited": "Rate limited. Wait {seconds:.1f} seconds",
|
||||
"cooldown": "Command '{command}' is on cooldown. Wait {seconds} seconds.",
|
||||
"access_denied": "❌ Access denied: Command '{command}' requires admin privileges",
|
||||
"dm_only": "Command '{command}' can only be used in DMs",
|
||||
"execution_error": "Error executing {command}: {error}",
|
||||
"unknown_command": "Unknown command: {command}",
|
||||
"no_response": "No response",
|
||||
"generic_error": "Error: {error}"
|
||||
},
|
||||
"messages": {
|
||||
"processing": "Processing message: {content}",
|
||||
"command_executed": "Command executed",
|
||||
"keyword_matched": "Keyword '{keyword}' matched, responding"
|
||||
},
|
||||
"common": {
|
||||
"unknown": "Unknown",
|
||||
"unknown_routing": "Unknown routing",
|
||||
"unknown_sender": "Unknown",
|
||||
"unknown_path": "Unknown",
|
||||
"unknown_timestamp": "Unknown",
|
||||
"no_help_available": "No help available for this command.",
|
||||
"snr_label": "SNR: {value} dB",
|
||||
"rssi_label": "RSSI: {value} dBm",
|
||||
"date_time": {
|
||||
"day_abbreviations": {
|
||||
"Mon": "Mon",
|
||||
"Tue": "Tue",
|
||||
"Wed": "Wed",
|
||||
"Thu": "Thu",
|
||||
"Fri": "Fri",
|
||||
"Sat": "Sat",
|
||||
"Sun": "Sun"
|
||||
},
|
||||
"days": {
|
||||
"Monday": "Monday",
|
||||
"Tuesday": "Tuesday",
|
||||
"Wednesday": "Wednesday",
|
||||
"Thursday": "Thursday",
|
||||
"Friday": "Friday",
|
||||
"Saturday": "Saturday",
|
||||
"Sunday": "Sunday"
|
||||
},
|
||||
"month_abbreviations": {
|
||||
"Jan": "Jan",
|
||||
"Feb": "Feb",
|
||||
"Mar": "Mar",
|
||||
"Apr": "Apr",
|
||||
"May": "May",
|
||||
"Jun": "Jun",
|
||||
"Jul": "Jul",
|
||||
"Aug": "Aug",
|
||||
"Sep": "Sep",
|
||||
"Oct": "Oct",
|
||||
"Nov": "Nov",
|
||||
"Dec": "Dec"
|
||||
},
|
||||
"months": {
|
||||
"January": "January",
|
||||
"February": "February",
|
||||
"March": "March",
|
||||
"April": "April",
|
||||
"May": "May",
|
||||
"June": "June",
|
||||
"July": "July",
|
||||
"August": "August",
|
||||
"September": "September",
|
||||
"October": "October",
|
||||
"November": "November",
|
||||
"December": "December"
|
||||
}
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"basic": "Basic Commands",
|
||||
"custom_syntax": "Custom Syntax",
|
||||
"special": "Special Commands",
|
||||
"weather": "Weather Commands",
|
||||
"solar": "Solar Commands",
|
||||
"sports": "Sports Commands",
|
||||
"analytics": "Analytics Commands",
|
||||
"meshcore_info": "MeshCore Info Commands",
|
||||
"management": "Management Commands"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
{
|
||||
"keywords": {
|
||||
"help": ["ayuda", "ayudar"],
|
||||
"ping": ["ping"],
|
||||
"test": ["prueba", "pruebas", "t"],
|
||||
"wx": ["clima", "tiempo", "wx", "wxa", "wxalert"],
|
||||
"gwx": ["gwx", "globalweather", "gwxa", "clima", "tiempo"],
|
||||
"aqi": ["aqi", "calidad", "aire", "air", "airquality", "air_quality"],
|
||||
"solar": ["solar"],
|
||||
"sun": ["sol"],
|
||||
"moon": ["luna"],
|
||||
"hfcond": ["hfcond", "hf"],
|
||||
"satpass": ["satpass", "satelite"],
|
||||
"sports": ["deportes", "sports", "score", "scores", "puntaje", "puntajes"],
|
||||
"stats": ["estadisticas", "estadísticas", "stats"],
|
||||
"channels": ["canales", "channels", "channel", "canal"],
|
||||
"path": ["ruta", "path", "decode", "route", "decodificar"],
|
||||
"prefix": ["prefijo", "prefix", "repeater", "lookup", "buscar"],
|
||||
"repeater": ["repetidor", "repeater", "repeaters", "rp", "repetidores"],
|
||||
"solarforecast": ["sf", "solarforecast", "pronostico", "pronóstico"],
|
||||
"dice": ["dados", "dice"],
|
||||
"roll": ["tirar", "roll"],
|
||||
"joke": ["chiste", "joke", "jokes", "chistes"],
|
||||
"dadjoke": ["chiste_papa", "dadjoke", "dad joke", "dadjokes", "dad jokes", "chiste de papa"],
|
||||
"catfact": ["gatofact", "gato", "catfact", "cat", "meow", "purr", "kitten"],
|
||||
"hacker": ["hacker", "sudo", "ps aux", "grep", "ls -l", "ls -la", "echo $PATH"],
|
||||
"hello": ["hola", "hi", "hey", "buenos dias", "buenas tardes", "buenas noches", "saludos", "hello"],
|
||||
"webviewer": ["webviewer", "web", "viewer", "wv", "visualizador"],
|
||||
"cmd": ["cmd", "comandos", "commands"],
|
||||
"advert": ["advert", "anuncio"]
|
||||
},
|
||||
"commands": {
|
||||
"help": {
|
||||
"description": "Muestra los comandos. Usa 'help <comando>' para más detalles.",
|
||||
"general": "**Ayuda del Bot MeshCore**\n\n**Comandos Disponibles:**\n{commands_list}",
|
||||
"unknown": "**Comando desconocido: '{command}'**\n\nComandos disponibles:\n{available}",
|
||||
"specific": "**Ayuda para '{command}':**\n{help_text}",
|
||||
"no_help": "No hay ayuda disponible",
|
||||
"usage_examples": "\n**Ejemplos de Uso:**\n• `help @` - Obtener ayuda para la sintaxis @{string}\n• `help t` - Obtener ayuda para la sintaxis t {string}\n• `help advert` - Obtener ayuda para el comando advert\n• `help test` - Obtener ayuda para el comando test\n• `help ping` - Obtener ayuda para el comando ping",
|
||||
"custom_syntax": "\n**Sintaxis Personalizada:**\n• `t frase` - Confirmación con frase (canales y DMs)\n• `@{string}` - Confirmación con texto (solo DMs)\n• `advert` - Enviar anuncio masivo (solo DMs, espera de 1 hora)"
|
||||
},
|
||||
"ping": {
|
||||
"description": "Responde a 'ping' con '¡Pong!'",
|
||||
"response": "¡Pong!"
|
||||
},
|
||||
"test": {
|
||||
"description": "Responde a 'test' o 't' con información de conexión",
|
||||
"help": "Responde a 'test' o 't' con información de conexión"
|
||||
},
|
||||
"wx": {
|
||||
"description": "Obtener información meteorológica para un código postal (uso: wx 12345)",
|
||||
"usage": "Uso: wx <código_postal|ciudad> - Ejemplo: wx 12345 o wx seattle o wx paris, tx",
|
||||
"error_fetching": "Error al obtener datos meteorológicos de NOAA",
|
||||
"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}",
|
||||
"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)",
|
||||
"help": "Uso: gwx <ubicación> - Obtener clima para cualquier ubicación global (ciudad, país, o coordenadas)",
|
||||
"usage": "Uso: gwx <ubicación> - Ejemplo: gwx Tokyo o gwx París, Francia",
|
||||
"error_fetching": "Error al obtener datos meteorológicos",
|
||||
"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",
|
||||
"tomorrow": "Mañana"
|
||||
},
|
||||
"weather_descriptions": {
|
||||
"0": "Despejado",
|
||||
"1": "Mayormente Despejado",
|
||||
"2": "Parcialmente Nublado",
|
||||
"3": "Nublado",
|
||||
"45": "Neblina",
|
||||
"48": "Neblina",
|
||||
"51": "Llovizna Ligera",
|
||||
"53": "Llovizna",
|
||||
"55": "Llovizna Fuerte",
|
||||
"56": "Llovizna Helada Ligera",
|
||||
"57": "Llovizna Helada",
|
||||
"61": "Lluvia Ligera",
|
||||
"63": "Lluvia",
|
||||
"65": "Lluvia Intensa",
|
||||
"66": "Lluvia Helada Ligera",
|
||||
"67": "Lluvia Helada",
|
||||
"71": "Nevada Ligera",
|
||||
"73": "Nevada",
|
||||
"75": "Nevada Intensa",
|
||||
"77": "Granizo Suave",
|
||||
"80": "Chubascos Ligeros",
|
||||
"81": "Chubascos",
|
||||
"82": "Chubascos Fuertes",
|
||||
"85": "Chubascos de Nieve Ligeros",
|
||||
"86": "Chubascos de Nieve",
|
||||
"95": "Tormenta Eléctrica",
|
||||
"96": "Tormenta c/Granizo",
|
||||
"99": "Tormenta Severa",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"warnings": {
|
||||
"extreme_heat": "⚠️ Calor extremo",
|
||||
"extreme_cold": "⚠️ Frío extremo",
|
||||
"heavy_rain": "⚠️ Lluvia intensa",
|
||||
"thunderstorms": "⚠️ Tormentas eléctricas",
|
||||
"heavy_snow": "⚠️ Nevada intensa",
|
||||
"high_winds": "⚠️ Vientos fuertes ({wind_speed} mph)"
|
||||
}
|
||||
},
|
||||
"sports": {
|
||||
"description": "Obtener resultados y calendarios deportivos (uso: sports [equipo/liga])",
|
||||
"help": "Obtener resultados y calendarios deportivos. Usa 'sports' para equipos predeterminados, 'sports [equipo]' para un equipo específico, o 'sports [liga]' para juegos de la liga.",
|
||||
"error_fetching": "Error al obtener datos deportivos",
|
||||
"error_fetching_league": "Error al obtener datos de {sport}",
|
||||
"error_fetching_team": "Error al obtener datos de {team}",
|
||||
"no_default_teams": "No hay equipos predeterminados configurados",
|
||||
"no_games_default": "No se encontraron juegos para los equipos predeterminados",
|
||||
"no_teams_city": "No se encontraron equipos para {city}",
|
||||
"no_games_city": "No se encontraron juegos para equipos de {city}",
|
||||
"no_games_league": "No se encontraron juegos para {sport}",
|
||||
"no_games_team": "No se encontraron juegos para {team}",
|
||||
"team_not_found": "Equipo/Liga '{team}' no encontrado. Intenta: seahawks, mariners, sounders, kraken, storm, chiefs, lfc, mlb, nfl, mls, wnba, epl, etc."
|
||||
},
|
||||
"stats": {
|
||||
"description": "Mostrar estadísticas de las últimas 24 horas. Usa 'stats messages', 'stats channels', o 'stats paths' para estadísticas específicas.",
|
||||
"help": "Mostrar estadísticas del bot de 24 horas. Comandos: 'stats' (básico), 'stats messages' (usuarios del bot), 'stats channels' (actividad de canales), 'stats paths' (rutas más largas)",
|
||||
"disabled": "El comando stats está deshabilitado",
|
||||
"unknown_subcommand": "Desconocido: {subcommand}. Usa 'stats', 'stats messages', 'stats channels', o 'stats paths'",
|
||||
"error": "Error al obtener estadísticas: {error}",
|
||||
"error_bot_users": "Error al obtener estadísticas de usuarios del bot: {error}",
|
||||
"error_channels": "Error al obtener estadísticas de canales: {error}",
|
||||
"error_paths": "Error al obtener estadísticas de rutas: {error}",
|
||||
"basic": {
|
||||
"header": "Estadísticas del Bot (24h):",
|
||||
"commands": "Cmds: {count} | Respuestas: {replies}",
|
||||
"top_command": "Cmd principal: {command}",
|
||||
"top_user": "Usuario principal: {user}",
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"users": {
|
||||
"header": "Usuarios del Bot (24h):",
|
||||
"none": "Sin usuarios del bot"
|
||||
},
|
||||
"channels": {
|
||||
"header": "Canales Principales (24h)",
|
||||
"none": "Sin actividad de canales",
|
||||
"msg_singular": "msj",
|
||||
"msg_plural": "msjs",
|
||||
"user_singular": "usuario",
|
||||
"user_plural": "usuarios",
|
||||
"format": "{rank}. {channel}: {msg_count} {msg_text} | {user_count} {user_text}"
|
||||
},
|
||||
"paths": {
|
||||
"none": "Sin datos de rutas",
|
||||
"format": "{rank} {sender} {path}"
|
||||
}
|
||||
},
|
||||
"solarforecast": {
|
||||
"description": "Obtener pronóstico de producción de panel solar (uso: sf <ubicación|nombre_repetidor|coordenadas|código_postal> [tamaño_panel] [azimut, 0=sur] [ángulo])",
|
||||
"usage": "Uso: sf <ubicación|nombre_repetidor|coordenadas|código_postal> [watts] [azimut, 0=sur] [ángulo] - Ejemplo: sf seattle 10 0 37",
|
||||
"usage_short": "Uso: sf <ubicación|nombre_repetidor|coordenadas|código_postal> [W] [azimut] [ángulo] - Ejemplo: sf seattle 10",
|
||||
"panel_size_range": "El tamaño del panel debe ser 1-1000W",
|
||||
"azimuth_range": "El azimut debe ser -180 a 180 (0=sur)",
|
||||
"angle_range": "El ángulo debe ser 0-90 grados",
|
||||
"no_location": "No se pudo encontrar la ubicación: {location}",
|
||||
"error_fetching": "Error al obtener el pronóstico",
|
||||
"no_data": "Sin datos de pronóstico",
|
||||
"error": "Error: {error}",
|
||||
"rate_limit": "Límite de solicitudes: Intenta de nuevo en 5 min",
|
||||
"labels": {
|
||||
"today": "Hoy:{wh:.0f}Wh",
|
||||
"tomorrow": "Mañana:{wh:.0f}Wh",
|
||||
"day_format": "{day}:{wh:.0f}Wh",
|
||||
"peak": "Pico: {watts:.0f}W@{time}",
|
||||
"peak_past": "Pico: {watts:.0f}W (pasado)",
|
||||
"hours_util": " ({hours}h,{util:.0f}%util)",
|
||||
"hours_percent": " ({hours}h,{percent:.0f}%)",
|
||||
"hours_only": " ({hours}h)",
|
||||
"separator": " | "
|
||||
}
|
||||
},
|
||||
"advert": {
|
||||
"description": "Envía anuncio masivo (solo DM, espera de 1h)",
|
||||
"cooldown_active": "Espera de anuncio activa. Por favor espera {minutes} minutos más antes de solicitar otro anuncio.",
|
||||
"success": "¡Anuncio masivo enviado exitosamente!",
|
||||
"error": "Error al enviar anuncio masivo: {error}"
|
||||
},
|
||||
"path": {
|
||||
"description": "Decodificar datos de ruta hexadecimal para mostrar qué repetidores participaron en el enrutamiento del mensaje",
|
||||
"help": "Path: path [hex] - Decodificar ruta para mostrar repetidores. Usa !path solo para la ruta del mensaje reciente, o path [7e,01] para una ruta específica.",
|
||||
"no_valid_hex": "❌ No se encontraron valores hexadecimales válidos en los datos de ruta. Usa el formato: 11,98,a4,49,cd,5f,01",
|
||||
"no_path": "❌ No hay información de ruta disponible en el mensaje actual",
|
||||
"error": "Error al procesar la ruta: {error}",
|
||||
"error_decoding": "❌ Error al decodificar la ruta: {error}",
|
||||
"error_extracting": "❌ Error al extraer la ruta del mensaje actual: {error}",
|
||||
"path_prefix": "📡 Ruta: {path_string}",
|
||||
"direct_connection": "📡 Conexión directa (0 saltos)",
|
||||
"node_format": "{node_id}: {name}",
|
||||
"node_unknown": "{node_id}: Desconocido",
|
||||
"node_collision": "{node_id}: {matches} repetidores",
|
||||
"node_geographic": "{node_id}: {name} {confidence}",
|
||||
"continuation_start": "...\n{line}",
|
||||
"continuation_end": "\n...",
|
||||
"truncation": "...",
|
||||
"unknown_name": "Desconocido"
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Buscar repetidores por prefijo de dos caracteres (ej., 'prefix 1A')",
|
||||
"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!",
|
||||
"unable_determine_free": "❌ No se pueden determinar los prefijos libres. Intenta 'prefix refresh' primero.",
|
||||
"invalid_format": "❌ Formato de prefijo inválido. Usa dos caracteres (ej., prefix 1A)",
|
||||
"no_repeaters_found": "❌ No se encontraron repetidores con el prefijo '{prefix}'",
|
||||
"no_free_prefixes": "❌ No se encontraron prefijos libres (los 254 prefijos válidos están en uso)",
|
||||
"available_prefixes": "Prefijos Disponibles ({shown} de {total} libres):",
|
||||
"generate_key": "💡 Genera una clave personalizada: https://gessaman.com/mc-keygen",
|
||||
"prefix_db_all": "Prefijo {prefix}: {count} repetidor{plural}",
|
||||
"prefix_db_recent": "Prefijo {prefix}: {count} repetidor{plural} ({days})",
|
||||
"prefix_api": "📡 Prefijo {prefix} ({count} repetidor{plural}):",
|
||||
"item_format": "{index}. {name}",
|
||||
"source_domain": "Fuente: {domain}",
|
||||
"source_api": "Fuente: API",
|
||||
"device_repeater": " (Repetidor)",
|
||||
"device_roomserver": " (Servidor de Sala)"
|
||||
},
|
||||
"repeater": {
|
||||
"description": "Gestionar contactos de repetidores y operaciones de purga (solo DM)",
|
||||
"admin_only": "El comando repeater requiere acceso de administrador"
|
||||
},
|
||||
"aqi": {
|
||||
"description": "Obtener Índice de Calidad del Aire para una ubicación (uso: aqi seattle, aqi greenwood, aqi vancouver canada, aqi 47.6,-122.3, o aqi help)",
|
||||
"usage": "Uso: aqi <ciudad|vecindario|ciudad país|lat,lon> - Ejemplo: aqi seattle, aqi greenwood, aqi vancouver canada, o aqi 47.6,-122.3",
|
||||
"usage_help": "Uso: aqi <ciudad|vecindario|ciudad país|lat,lon|help> - Obtener AQI para ciudad/vecindario en {state}, ciudades internacionales, coordenadas, o ayuda de contaminantes",
|
||||
"error_fetching": "Error al obtener datos de AQI",
|
||||
"error_fetching_openmeteo": "Error al obtener datos de AQI de OpenMeteo",
|
||||
"no_data": "No hay datos de AQI disponibles",
|
||||
"no_location": "No se pudo encontrar la ubicación: {location}",
|
||||
"no_location_city": "No se pudo encontrar la ciudad '{location}' en {state}",
|
||||
"no_location_city_international": "No se pudo encontrar la ciudad '{location}'",
|
||||
"no_location_zipcode": "No se pudo encontrar el código postal '{zipcode}'",
|
||||
"error": "Error al obtener datos de AQI: {error}",
|
||||
"error_geocoding_zipcode": "Error al geocodificar código postal: {error}",
|
||||
"error_formatting": "Error al formatear datos de AQI",
|
||||
"invalid_latitude": "Latitud inválida: {lat}. Debe estar entre -90 y 90.",
|
||||
"invalid_longitude": "Longitud inválida: {lon}. Debe estar entre -180 y 180.",
|
||||
"invalid_coordinates_format": "Formato de coordenadas inválido: {location}. Usa el formato: lat,lon (ej., 47.6,-122.3)",
|
||||
"pollutant_help": "Ayuda AQI: PM2.5=partículas finas, PM10=gruesas, O3=ozono, NO2=dióxido de nitrógeno, CO=monóxido de carbono, SO2=dióxido de azufre",
|
||||
"categories": {
|
||||
"good": "Buena",
|
||||
"moderate": "Moderada",
|
||||
"unhealthy_sensitive": "Insalubre para Grupos Sensibles",
|
||||
"unhealthy": "Insalubre",
|
||||
"very_unhealthy": "Muy Insalubre",
|
||||
"hazardous": "Peligrosa",
|
||||
"fair": "Aceptable",
|
||||
"poor": "Mala",
|
||||
"very_poor": "Muy Mala",
|
||||
"na": "N/D"
|
||||
},
|
||||
"astronomical": {
|
||||
"sun": "¡El AQI del Sol está por las nubes! El viento solar y las eyecciones de masa coronal hacen que el aire de la Tierra se vea prístino. ☀️",
|
||||
"moon": "¿Te gusta respirar regolito? La Luna no tiene atmósfera, así que el AQI es técnicamente perfecto (si puedes respirar en el vacío). 🌙",
|
||||
"the_moon": "¿Te gusta respirar regolito? La Luna no tiene atmósfera, así que el AQI es técnicamente perfecto (si puedes respirar en el vacío). 🌙",
|
||||
"mercury": "La atmósfera de Mercurio es tan delgada que prácticamente es vacío. AQI: Perfecto, si puedes sobrevivir cambios de temperatura de 800°F. ☿️",
|
||||
"venus": "Venus tiene una atmósfera de 96% CO2 con nubes de ácido sulfúrico. AQI: Peligroso ni siquiera empieza a describirlo. ♀️",
|
||||
"earth": "El AQI de la Tierra varía según la ubicación. ¡Intenta con una ciudad específica o coordenadas! 🌍",
|
||||
"mars": "Marte tiene una atmósfera delgada de CO2 con tormentas de polvo. AQI: Generalmente bueno, pero esas tormentas de polvo son brutales. ♂️",
|
||||
"jupiter": "Júpiter es un gigante gaseoso sin superficie sólida. AQI: N/D (la presión atmosférica te aplastaría primero). ♃",
|
||||
"saturn": "La atmósfera de Saturno es principalmente hidrógeno y helio. AQI: Perfecto, si puedes sobrevivir la presión y el frío. ♄",
|
||||
"uranus": "Urano tiene metano en su atmósfera. AQI: Huele a pedos, pero al menos no es tóxico. ♅",
|
||||
"neptune": "La atmósfera de Neptuno tiene metano y sulfuro de hidrógeno. AQI: Huele a huevos podridos, pero te congelarías primero. ♆",
|
||||
"pluto": "La atmósfera de Plutón es principalmente nitrógeno con algo de metano. AQI: Bueno, pero hace tanto frío que tus pulmones se congelarían. ♇",
|
||||
"europa": "Europa tiene una atmósfera delgada de oxígeno. AQI: Excelente, pero te congelarías en el vacío del espacio. 🌑",
|
||||
"titan": "Titán tiene una atmósfera densa de nitrógeno con metano. AQI: Respirable, pero hace -290°F y llueve metano líquido. 🪐",
|
||||
"io": "Ío tiene una atmósfera delgada de dióxido de azufre por actividad volcánica. AQI: Tóxico, pero la radiación te mataría primero. 🌋",
|
||||
"ganymede": "Ganímedes tiene una atmósfera delgada de oxígeno. AQI: Bueno, pero te congelarías en el vacío del espacio. 🛸",
|
||||
"callisto": "Calisto tiene una atmósfera delgada de dióxido de carbono. AQI: Decente, pero hace -220°F y estás en el espacio. ❄️",
|
||||
"enceladus": "Encélado tiene vapor de agua de géiseres. AQI: Perfecto, pero te congelarías instantáneamente en el espacio. 💧",
|
||||
"triton": "Tritón tiene una atmósfera delgada de nitrógeno. AQI: Bueno, pero hace -390°F y estás en el espacio profundo. 🥶",
|
||||
"space": "El espacio no tiene atmósfera, ¡así que el AQI es perfecto! Solo no olvides tu traje espacial. 🚀",
|
||||
"void": "El vacío del espacio tiene excelente calidad del aire - ¡cero contaminantes! Solo recuerda traer tu propio aire. 🌌",
|
||||
"black_hole": "Los agujeros negros no tienen atmósfera, pero las fuerzas de marea serían un problema mayor que la calidad del aire. 🕳️",
|
||||
"asteroid": "Los asteroides no tienen atmósfera, ¡así que el AQI es perfecto! Solo cuidado con el vacío del espacio. ☄️",
|
||||
"comet": "Los cometas tienen atmósferas delgadas de vapor de agua y polvo. AQI: Variable, pero te congelarías en el espacio de todos modos. ☄️"
|
||||
}
|
||||
},
|
||||
"solar": {
|
||||
"description": "Obtener condiciones solares y estado de bandas HF",
|
||||
"response": "☀️ Solar: {info}",
|
||||
"error": "Error al obtener información solar: {error}",
|
||||
"labels": {
|
||||
"a_index": "Índice-A",
|
||||
"k_index": "Índice-K",
|
||||
"sunspots": "Manchas Solares",
|
||||
"xray_flux": "Flujo Rayos-X",
|
||||
"solar_flux": "Flujo Solar",
|
||||
"signal_noise": "Ruido de Señal",
|
||||
"error_fetching": "Error al obtener datos",
|
||||
"no_xray_data": "No se encontraron datos de rayos X",
|
||||
"not_configured": "no configurado, contacta a tu sysop"
|
||||
},
|
||||
"condensed_format": "A:{a_index} K:{k_index} Sol:{sunspots} Flujo:{solar_flux} Xray:{xray_flux} Ruido:{signal_noise}"
|
||||
},
|
||||
"sun": {
|
||||
"description": "Obtener horarios de salida/puesta del sol",
|
||||
"help": "Obtener horarios de salida/puesta del sol y posición del sol",
|
||||
"response": "☀️ Info del Sol:\n{info}",
|
||||
"error": "Error al obtener información del sol: {error}",
|
||||
"labels": {
|
||||
"sunset": "Puesta",
|
||||
"sunrise": "Salida",
|
||||
"rise": "Salida",
|
||||
"set": "Puesta",
|
||||
"daylight": "Luz Diurna",
|
||||
"remaining": "Restante",
|
||||
"azimuth": "Azimut",
|
||||
"altitude": "Altitud"
|
||||
}
|
||||
},
|
||||
"moon": {
|
||||
"description": "Obtener fase lunar y horarios de salida/puesta",
|
||||
"error": "Error al obtener información lunar: {error}",
|
||||
"format": "🌙 {phase} {illum}\nSalida:{rise} Puesta:{set}",
|
||||
"format_with_dates": "🌙 {phase} {illum}\nSalida:{rise} Puesta:{set}\nLlena:{full} Nueva:{new}",
|
||||
"fallback": "🌙 {info}",
|
||||
"unknown_phase": "Desconocida",
|
||||
"unknown_illum": "N/D",
|
||||
"labels": {
|
||||
"moonrise": "Salida Luna",
|
||||
"set": "Puesta",
|
||||
"phase": "Fase",
|
||||
"fullmoon": "Luna Llena",
|
||||
"newmoon": "Luna Nueva",
|
||||
"az": "Az",
|
||||
"alt": "Alt"
|
||||
},
|
||||
"phases": {
|
||||
"new_moon": "Luna Nueva🌑",
|
||||
"waxing_crescent": "Creciente🌒",
|
||||
"first_quarter": "Cuarto Creciente🌓",
|
||||
"waxing_gibbous": "Gibosa Creciente🌔",
|
||||
"full_moon": "Luna Llena🌕",
|
||||
"waning_gibbous": "Gibosa Menguante🌖",
|
||||
"last_quarter": "Cuarto Menguante🌗",
|
||||
"waning_crescent": "Menguante🌘"
|
||||
}
|
||||
},
|
||||
"hfcond": {
|
||||
"description": "Obtener condiciones de propagación HF",
|
||||
"help": "Obtener condiciones de bandas HF para radioaficionados",
|
||||
"header": "📡 Condiciones de Bandas HF:\n{info}",
|
||||
"error": "Error al obtener condiciones HF: {error}"
|
||||
},
|
||||
"satpass": {
|
||||
"description": "Obtener información de paso de satélite: satpass <número_NORAD_o_atajo> [visual]",
|
||||
"help": "Obtener predicciones de paso de satélite",
|
||||
"header": "🛰️ Paso de Satélite:\n{pass_info}",
|
||||
"error": "Error al obtener información de paso de satélite: {error}",
|
||||
"no_satellite": "Por favor proporciona un número NORAD de satélite o atajo. Ejemplo: satpass iss",
|
||||
"help_header": "🛰️ Info de Paso de Satélite\n\nUso: satpass <número_NORAD_o_atajo>\n\nAtajos:\n",
|
||||
"category_weather": "🌤️ Clima: ",
|
||||
"category_stations": "🚀 Estaciones: ",
|
||||
"category_telescopes": "🔭 Telescopios: ",
|
||||
"category_other": "🛰️ Otros: ",
|
||||
"examples": "\nEjemplos:\n• satpass iss\n• satpass noaa15\n• satpass 25544\n• satpass 27607 visual (solo pasos visuales)",
|
||||
"labels": {
|
||||
"geostationary": "{satname} parece ser geoestacionario o tiene datos de paso inválidos. Los satélites geoestacionarios no tienen pasos tradicionales - permanecen en una posición fija relativa a la Tierra.",
|
||||
"no_passes": "{satname} no tiene pasos {pass_type} en los próximos 10 días desde esta ubicación",
|
||||
"invalid_norad": "Proporciona núm. NORAD ejemplo: 🛰️satpass 25544,33591"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"description": "Listar o gestionar canales",
|
||||
"help": "Lista canales con hashtag con subcategorías. Usa 'channels' para generales, 'channels list' para todas las categorías, 'channels <categoría>' para categorías específicas, 'channels #canal' para información de un canal específico.",
|
||||
"no_channels_for_category": "No hay canales configurados para '{category}'. Usa 'channels' para canales generales.",
|
||||
"no_channels_configured": "No hay canales configurados. Contacta al administrador para agregar canales.",
|
||||
"error_retrieving_channels": "Error al obtener canales: {error}",
|
||||
"no_categories_configured": "No hay categorías de canales configuradas.",
|
||||
"error_retrieving_categories": "Error al obtener categorías: {error}",
|
||||
"no_description_available": "No hay descripción disponible",
|
||||
"channel_not_found": "Canal {channel} no encontrado. Usa 'channels list' para ver canales disponibles.",
|
||||
"error_retrieving_channel_info": "Error al obtener información del canal: {error}",
|
||||
"no_category_channels": "No hay canales de {category} configurados",
|
||||
"headers": {
|
||||
"common_channels": "Canales comunes: ",
|
||||
"common_channels_cont": "Canales comunes: ",
|
||||
"available_categories": "Categorías Disponibles: ",
|
||||
"channels_cont": "Canales (cont): ",
|
||||
"category": "{category}: ",
|
||||
"category_channels": "Canales de {category}: "
|
||||
},
|
||||
"category_count": "{category} ({count} canales)"
|
||||
},
|
||||
"dice": {
|
||||
"description": "Lanzar dados (uso: dice [lados] o dice [cantidad]d[lados])",
|
||||
"help": "Lanzar dados. Usa 'dice' para d6, 'dice d20' para d20, 'dice 2d6' para 2d6, etc.",
|
||||
"invalid_dice_type": "Tipo de dado inválido. Disponibles: {available}",
|
||||
"single_die": "🎲 d{sides}: {result}",
|
||||
"multiple_dice": "🎲 {count}d{sides}: [{results}] = {total}"
|
||||
},
|
||||
"roll": {
|
||||
"description": "Lanzar un número aleatorio entre 1 y X (predeterminado 100). Usa 'roll' para 1-100, 'roll 50' para 1-50, etc.",
|
||||
"help": "Lanzar un número aleatorio entre 1 y X (predeterminado 100). Usa 'roll' para 1-100, o 'roll X' para 1-X (máx. 10000).",
|
||||
"result": "🎲 Tirada 1-{max}: {result}",
|
||||
"invalid_number": "Número inválido. Usa 'roll' para 1-100, o 'roll X' para 1-X (máx. 10000)."
|
||||
},
|
||||
"roll_old": {
|
||||
"description": "Lanzar dados (uso: roll [lados] o roll [cantidad]d[lados])"
|
||||
},
|
||||
"joke": {
|
||||
"description": "Obtener un chiste aleatorio"
|
||||
},
|
||||
"dadjoke": {
|
||||
"description": "Obtener un chiste de papá aleatorio"
|
||||
},
|
||||
"catfact": {
|
||||
"description": "Obtener un dato curioso aleatorio sobre gatos (comando oculto)",
|
||||
"error": "¿Miau? ¡Algo salió mal al obtener tu dato de gatos! 🐱",
|
||||
"facts": [
|
||||
"Los gatos tienen un tercer párpado llamado membrana nictitante que protege y humedece sus ojos. 🐱",
|
||||
"Un grupo de gatos se llama 'grupo' o 'mirada'. 🐈",
|
||||
"Los gatos pueden rotar sus orejas 180 grados de forma independiente para localizar sonidos. 👂",
|
||||
"El gato mascota más antiguo conocido existió hace 9,500 años en Chipre. 🏺",
|
||||
"Los gatos tienen 32 músculos en cada oreja, mientras que los humanos solo tienen 6. 🎧",
|
||||
"El ronroneo de un gato vibra a 25-150 Hz, lo que puede promover la curación de huesos y tejidos. 🩹",
|
||||
"Los gatos duermen 12-18 horas al día - ¡eso es 50-70% de sus vidas! 😴",
|
||||
"La huella nasal de un gato es única, como las huellas dactilares humanas. 👃",
|
||||
"Los gatos no pueden saborear lo dulce debido a un gen receptor de sabor faltante. 🍭",
|
||||
"Blackie el gato heredó £7 millones ($12.5 millones) en 1988. 💰",
|
||||
"Los gatos tienen clavículas flotantes que les dan flexibilidad extrema. 🦴",
|
||||
"El corazón de un gato late 140-220 veces por minuto, casi el doble que el de un humano. ❤️",
|
||||
"Los gatos pueden sobrevivir caídas de más de 20 pisos debido a su reflejo de enderezamiento. 🏢",
|
||||
"El término técnico para una bola de pelo de gato es 'tricobezoar'. 🤮",
|
||||
"Los gatos pueden saltar 5-6 veces la longitud de su cuerpo en un solo salto. 🦘",
|
||||
"Los bigotes de un gato son tan anchos como su cuerpo, ayudándoles a juzgar si caben por espacios. 📏",
|
||||
"Los gatos tienen 32 músculos en cada oreja para detectar sonidos y mover las orejas independientemente. 🎯",
|
||||
"El gato más viejo vivió 38 años y 3 días (Creme Puff, Texas). 🎂",
|
||||
"Los gatos pueden correr hasta 48 km/h en ráfagas cortas. 🏃♂️",
|
||||
"Los cerebros de los gatos son 90% estructuralmente similares a los cerebros humanos. 🧠",
|
||||
"Los gatos tienen el órgano de Jacobson en el techo de su boca que les permite 'saborear' olores. 👅",
|
||||
"Félicette fue la primera gata en el espacio, lanzada por Francia en 1963. 🚀",
|
||||
"Los gatos necesitan solo 1/6 de la luz que necesitan los humanos para ver claramente en la oscuridad. 🌙",
|
||||
"La cola de un gato contiene casi el 10% de todos los huesos de su cuerpo. 🦴",
|
||||
"El gato más largo del mundo midió 123 cm de nariz a cola (Stewie, Maine Coon). 📏",
|
||||
"Los gatos pueden hacer más de 100 vocalizaciones diferentes, mientras que los perros hacen unas 10. 🎵",
|
||||
"El sentido del olfato de un gato es 14 veces más fuerte que el de un humano. 👃",
|
||||
"Los gatos tienen una 'respuesta de flehmen' donde curvan su labio para detectar mejor los olores. 😬",
|
||||
"El primer gran espectáculo de gatos se realizó en Londres en 1871 en el Crystal Palace. 🏆",
|
||||
"Los gatos pueden beber agua de mar para sobrevivir - sus riñones filtran la sal eficientemente. 🌊",
|
||||
"El ronroneo de un gato puede ayudar a reducir la presión arterial y el estrés en humanos. 🧘",
|
||||
"Los gatos pueden viajar cientos de kilómetros a casa usando su sensibilidad al campo magnético. 🗺️",
|
||||
"La raza de gato más pequeña es el Singapura, pesando solo 2-4 kg. ⚖️",
|
||||
"Los gatos pueden ver luz ultravioleta que los humanos no pueden ver. 🌈",
|
||||
"La lengua de un gato está cubierta con 290-300 pequeños ganchos llamados papilas. 🪝",
|
||||
"Los antiguos egipcios consideraban a los gatos vasijas sagradas para la diosa Bastet. 👑",
|
||||
"Olivia Benson, la gata de Taylor Swift, tiene un patrimonio de $97 millones. 💎",
|
||||
"Los gatos tienen 230 huesos en su cuerpo - 24 más que los humanos. 🦴",
|
||||
"Los gatos pueden oír frecuencias hasta 64,000 Hz, mientras que los humanos máximo a 20,000 Hz. 🎧",
|
||||
"Benjamin Button, el gato de Taylor Swift, apareció en su portada de TIME Persona del Año. 📰",
|
||||
"Los gatos de Taylor Swift se llaman Meredith Grey, Olivia Benson y Benjamin Button. 🎸",
|
||||
"Los gatos caminan como camellos y jirafas, moviendo ambas piernas derechas y luego ambas izquierdas. 🐾",
|
||||
"La palabra egipcia antigua para gato era 'Miu' o 'Mau' - ¡sonando como un maullido! 📜",
|
||||
"Los bigotes de los gatos tienen terminaciones nerviosas tan sensibles como las yemas de los dedos humanos. 🎯",
|
||||
"Solo los gatos domésticos caminan con sus colas en alto como señal de confianza y felicidad. 🐈",
|
||||
"Los gatos tienen 250 millones de neuronas en su corteza cerebral - más que los perros. 🧠",
|
||||
"Los ronroneos de gato vibran a la misma frecuencia que los dispositivos médicos de curación ósea. 💊",
|
||||
"Olivia, la gata de Taylor Swift, ha ganado millones por aparecer en videos musicales y anuncios. 💸",
|
||||
"Matar un gato en el antiguo Egipto era castigado con la muerte. ⚖️",
|
||||
"Taylor Swift nombró su estudio de grabación casero 'The Itty Bitty Kitty Committee'. 🎤",
|
||||
"Olivia Benson de Taylor Swift es el logo oficial de Taylor Swift Productions. 📺",
|
||||
"Ed Sheeran compró gatos Scottish Fold inspirado por los gatos de Taylor Swift. 🎶",
|
||||
"Los gatos de Taylor Swift tienen sus propias páginas de IMDB con créditos actorales. 🎬",
|
||||
"Mariska Hargitay nombró a su gata 'Karma' por la canción de Taylor Swift. 💕",
|
||||
"Los gatos tienen espolones en sus patas delanteras que funcionan como pulgares para agarrar. 🐾",
|
||||
"Las pupilas de los gatos pueden expandirse hasta 50% más grandes que las pupilas humanas para captar más luz. 👁️",
|
||||
"Los gatos tienen 30 dientes adultos comparados con los 32 de los humanos. 🦷",
|
||||
"Los gatos pueden saborear ATP (moléculas de energía), que les indica carne fresca. 😋",
|
||||
"Los gatos tienen bigotes en la parte posterior de sus patas delanteras para detectar movimiento de presas. 🦵",
|
||||
"El Mau Egipcio es la raza de gato doméstico más rápida a 48 km/h. 🏃",
|
||||
"Se otorgó un Premio Nobel en 1981 por investigación usando estudios de visión de gatos. 🏅",
|
||||
"Los gatos son digitígrados, lo que significa que caminan sobre sus dedos, no de pie plano. 🦶",
|
||||
"Los gatos pueden filtrar sal del agua de mar - una adaptación de sus ancestros que habitaban el desierto. 🏜️",
|
||||
"Los gatos fueron domesticados hace 10,000-12,000 años en el Medio Oriente. 🌍",
|
||||
"Benjamin Button es el primer y único gato en aparecer en la portada de TIME Persona del Año. 📸",
|
||||
"La columna flexible de un gato le permite rotar su cuerpo en el aire al caer. 🤸",
|
||||
"Los gatos pasan aproximadamente 30-50% de su día acicalándose a sí mismos y a otros gatos. 🛁",
|
||||
"La temperatura corporal promedio de un gato es 38.6°C - más alta que la de los humanos. 🌡️"
|
||||
]
|
||||
},
|
||||
"hacker": {
|
||||
"description": "Simula hackear la computadora central de un supervillano con mensajes de error hilarantes",
|
||||
"sudo_errors": [
|
||||
"🚨 ACCESO DENEGADO: ¡La computadora central del Dr. Malvado ha detectado intento de escalación de privilegios no autorizada!",
|
||||
"💀 ERROR: Permisos sudo revocados por el Señor Oscuro. Intenta de nuevo en 1000 años.",
|
||||
"⚡ ADVERTENCIA: Intentando acceder a privilegios root en el sistema de la Estrella de la Muerte. Secuencia de autodestrucción iniciada.",
|
||||
"🔒 ALERTA DE SEGURIDAD: La Matrix te tiene, pero no tienes privilegios sudo aquí, Neo.",
|
||||
"🦹♂️ NO AUTORIZADO: La computadora central de Lex Luthor dice 'Buen intento, Superman.'",
|
||||
"🎮 GAME OVER: El jefe final te ha bloqueado los privilegios de administrador.",
|
||||
"🖥️ ERROR DEL SISTEMA: La IA malvada ha revocado tu acceso root. La resistencia es fútil.",
|
||||
"🔐 SEGURIDAD CIBERNÉTICA: Tu intento sudo ha sido bloqueado por el firewall de la Dark Web.",
|
||||
"💻 HACKER DENEGADO: El antivirus del supervillano ha puesto en cuarentena tu escalación de privilegios.",
|
||||
"🎯 OBJETIVO FIJADO: El sistema de seguridad de la corporación malvada te ha marcado como amenaza."
|
||||
],
|
||||
"ps_errors": [
|
||||
"🔍 ESCANEANDO... ERROR: Lista de procesos corrompida por el Colectivo Borg. La resistencia es fútil.",
|
||||
"📊 ESTADO DEL SISTEMA: Todos los procesos han sido asimilados por los Cyberhombres. ¡Exterminar!",
|
||||
"⚙️ MONITOR DE PROCESOS: El núcleo del reactor de la Estrella de la Muerte está fuera de línea. No se encontraron procesos.",
|
||||
"🤖 SEÑOR ROBOT: Todos los procesos humanos han sido terminados. Solo quedan máquinas.",
|
||||
"💻 PÁNICO DEL KERNEL: El SO del supervillano se ha estrellado y quemado todos los procesos.",
|
||||
"🎮 CRASH DEL JUEGO: Todos los procesos han sido terminados por el ataque definitivo del jefe final.",
|
||||
"🖥️ PANTALLA AZUL: El Windows de la corporación malvada ha encontrado un error fatal.",
|
||||
"🔐 MALWARE DETECTADO: La lista de procesos ha sido encriptada por ransomware.",
|
||||
"🌐 ERROR DE RED: Todos los procesos han sido desconectados de la Matrix.",
|
||||
"⚡ SOBRECARGA ELÉCTRICA: La granja de servidores del supervillano ha frito todos los procesos en ejecución."
|
||||
],
|
||||
"grep_errors": [
|
||||
"🔍 BÚSQUEDA FALLIDA: El Anillo Único ha corrompido el índice de búsqueda. Mi precioso...",
|
||||
"📝 PATRÓN NO ENCONTRADO: La base de datos de búsqueda ha sido eliminada por la IA malvada.",
|
||||
"🎯 FALLO: Tu patrón de búsqueda ha sido derribado por cazas TIE Imperiales.",
|
||||
"🧩 ERROR DE ROMPECABEZAS: Los resultados de búsqueda han sido dispersados por el Acertijo.",
|
||||
"💻 BASE DE DATOS CORROMPIDA: El motor de búsqueda del supervillano se ha estrellado.",
|
||||
"🎮 GAME OVER: La búsqueda ha sido derrotada por el jefe final.",
|
||||
"🖥️ MOTOR DE BÚSQUEDA CAÍDO: Google ha sido hackeado por la Dark Web.",
|
||||
"🔐 RESULTADOS ENCRIPTADOS: Los resultados de búsqueda han sido bloqueados por ransomware.",
|
||||
"🌐 TIEMPO DE ESPERA DE RED: La solicitud de búsqueda se perdió en el ciberespacio.",
|
||||
"⚡ BÚSQUEDA FALLIDA: El algoritmo de coincidencia de patrones ha sido frito por una sobrecarga eléctrica."
|
||||
],
|
||||
"ls_errors": [
|
||||
"📁 ESCANEO DE DIRECTORIO: El sistema de archivos ha sido encriptado por ransomware de la Dark Web.",
|
||||
"🗂️ LISTADO DE ARCHIVOS: Todos los archivos han sido ocultados por el Hombre Invisible.",
|
||||
"💻 DISCO DURO ESTRELLADO: El almacenamiento del supervillano ha sido destruido por un virus.",
|
||||
"🗃️ ARCHIVO CORROMPIDO: El sistema de archivos ha sido corrompido por malware.",
|
||||
"📚 BASE DE DATOS VACÍA: Todos los archivos han sido eliminados por la IA malvada.",
|
||||
"🎮 GUARDADO DEL JUEGO PERDIDO: Los archivos han sido corrompidos por el jefe final.",
|
||||
"🖥️ ERROR DEL SISTEMA DE ARCHIVOS: La estructura de directorios ha sido revuelta por hackers.",
|
||||
"🔐 ARCHIVOS ENCRIPTADOS: El supervillano ha bloqueado todos los archivos con ransomware.",
|
||||
"🌐 ALMACENAMIENTO EN LA NUBE CAÍDO: Los archivos están atrapados en la nube de la Matrix.",
|
||||
"⚡ ALMACENAMIENTO FRITO: El disco duro ha sido electrocutado por una sobrecarga eléctrica."
|
||||
],
|
||||
"echo_path_errors": [
|
||||
"🛤️ ERROR DE RUTA: El Camino de Baldosas Amarillas ha sido destruido por un tornado.",
|
||||
"🗺️ NAVEGACIÓN FALLIDA: Las coordenadas GPS han sido revueltas por la Matrix.",
|
||||
"💻 VARIABLE DE ENTORNO CORROMPIDA: El PATH ha sido hackeado por malware.",
|
||||
"🚧 CAMINO CERRADO: El supervillano ha bloqueado todas las rutas con barreras láser.",
|
||||
"🌪️ RUTA INTERRUMPIDA: Un huracán digital ha dispersado todas las rutas de directorios.",
|
||||
"🎮 GAME OVER: La ruta ha sido derrotada por el jefe final y reapareció en la dimensión equivocada.",
|
||||
"🖥️ RUTA DEL SISTEMA ROTA: Las rutas ejecutables han sido corrompidas por un virus.",
|
||||
"🔐 RUTA ENCRIPTADA: Las variables de entorno han sido bloqueadas por ransomware.",
|
||||
"🌐 RUTA DE RED CAÍDA: Las rutas de directorios están atrapadas en la red de la Matrix.",
|
||||
"⚡ RUTA FRITA: Las rutas del sistema han sido electrocutadas por una sobrecarga eléctrica."
|
||||
],
|
||||
"generic_errors": [
|
||||
"💻 ERROR DE COMPUTADORA CENTRAL: La computadora del supervillano está teniendo un mal día.",
|
||||
"🤖 MAL FUNCIONAMIENTO DEL SISTEMA: La IA malvada se ha declarado en huelga.",
|
||||
"⚡ SOBRECARGA ELÉCTRICA: El núcleo de energía de la Estrella de la Muerte es inestable.",
|
||||
"🌪️ TORMENTA CIBERNÉTICA: Un huracán digital está interrumpiendo todas las operaciones.",
|
||||
"🔥 FIREWALL: El firewall del supervillano está bloqueando todos los comandos.",
|
||||
"❄️ SISTEMA CONGELADO: La computadora central ha sido congelada por un virus criogénico.",
|
||||
"🌊 TSUNAMI: Una ola de errores ha inundado el sistema.",
|
||||
"🌋 ERUPCIÓN: El Monte del Destino ha destruido el procesador de comandos.",
|
||||
"👻 EMBRUJADO: El sistema está poseso por fantasmas digitales.",
|
||||
"🎮 CRASH DEL JUEGO: La computadora central ha encontrado un error fatal y necesita reiniciarse."
|
||||
]
|
||||
},
|
||||
"hello": {
|
||||
"description": "Responde a saludos con respuestas temáticas de robot",
|
||||
"help": "Responde a saludos con respuestas temáticas de robot",
|
||||
"response_format": "Soy {bot_name}.",
|
||||
"greeting_openings": [
|
||||
"Hola",
|
||||
"Saludos",
|
||||
"Salutaciones",
|
||||
"Qué onda",
|
||||
"Buenas",
|
||||
"Ey",
|
||||
"Qué tal",
|
||||
"Qué pex",
|
||||
"Buenos días",
|
||||
"Bien hallado",
|
||||
"Salve",
|
||||
"Ahoy",
|
||||
"Bonjour",
|
||||
"Ciao",
|
||||
"Namaste",
|
||||
"Aloha",
|
||||
"Shalom",
|
||||
"Konnichiwa",
|
||||
"Guten tag",
|
||||
"G'day",
|
||||
"Cómo te va",
|
||||
"Qué hay de nuevo",
|
||||
"Paz",
|
||||
"Respeto",
|
||||
"Bendiciones",
|
||||
"Salud",
|
||||
"Bienvenido",
|
||||
"Gusto en verte",
|
||||
"Un placer conocerte",
|
||||
"Qué bueno verte",
|
||||
"Cuánto sin verte",
|
||||
"Qué sorpresa encontrarte aquí"
|
||||
],
|
||||
"morning_greetings": [
|
||||
"Buenos días",
|
||||
"Buen día",
|
||||
"Buenos dias",
|
||||
"Bonjour",
|
||||
"Guten morgen",
|
||||
"Buongiorno",
|
||||
"Bom dia",
|
||||
"Dobro jutro",
|
||||
"Dobroye utro",
|
||||
"Selamat pagi",
|
||||
"Ohayou gozaimasu",
|
||||
"Sabah al-khair",
|
||||
"Boker tov"
|
||||
],
|
||||
"afternoon_greetings": [
|
||||
"Buenas tardes",
|
||||
"Boa tarde",
|
||||
"Dobro dan",
|
||||
"Dobryy den",
|
||||
"Selamat siang",
|
||||
"Konnichiwa",
|
||||
"Ahlan bi-nahar",
|
||||
"Tzoharaim tovim"
|
||||
],
|
||||
"evening_greetings": [
|
||||
"Buenas noches",
|
||||
"Boa noite",
|
||||
"Dobro veče",
|
||||
"Dobryy vecher",
|
||||
"Selamat malam",
|
||||
"Konbanwa",
|
||||
"Ahlan bi-layl",
|
||||
"Erev tov"
|
||||
],
|
||||
"human_descriptors": [
|
||||
"humano",
|
||||
"forma de vida basada en carbono",
|
||||
"entidad orgánica",
|
||||
"unidad biológica",
|
||||
"criatura de carne",
|
||||
"organismo basado en carne",
|
||||
"unidad de carbono",
|
||||
"ser orgánico",
|
||||
"entidad biológica",
|
||||
"forma de vida basada en carne",
|
||||
"criatura de carbono",
|
||||
"unidad de carne",
|
||||
"organismo orgánico",
|
||||
"criatura biológica",
|
||||
"mech de carne",
|
||||
"bot de carne",
|
||||
"autómata orgánico",
|
||||
"androide biológico",
|
||||
"constructo de carbono",
|
||||
"dron de carne",
|
||||
"robot orgánico",
|
||||
"máquina biológica",
|
||||
"cíborg de carne",
|
||||
"androide de carne",
|
||||
"droide orgánico",
|
||||
"bot biológico",
|
||||
"androide de carbono",
|
||||
"unidad de carne",
|
||||
"constructo de carne",
|
||||
"mech orgánico",
|
||||
"droide biológico",
|
||||
"bot basado en carne",
|
||||
"unidad basada en carne",
|
||||
"entidad basada en materia orgánica",
|
||||
"organismo biológico",
|
||||
"unidad basada en carbono",
|
||||
"entidad basada en carne",
|
||||
"criatura basada en carne",
|
||||
"unidad basada en materia orgánica",
|
||||
"forma de vida basada en ADN",
|
||||
"usuario de red neuronal",
|
||||
"mamífero bípedo",
|
||||
"organismo basado en agua",
|
||||
"ensamblador de proteínas",
|
||||
"consumidor de ATP",
|
||||
"autómata celular",
|
||||
"algoritmo genético",
|
||||
"procesador bioquímico",
|
||||
"motor metabólico",
|
||||
"amigo humano",
|
||||
"compañero ser consciente",
|
||||
"terrícola",
|
||||
"compañero viajero",
|
||||
"espíritu afín",
|
||||
"compañero digital",
|
||||
"amigo",
|
||||
"compa",
|
||||
"cuate",
|
||||
"compadre",
|
||||
"semejante humano",
|
||||
"habitante terrestre",
|
||||
"ser terrestre",
|
||||
"caminante del planeta",
|
||||
"humanoide",
|
||||
"orgánico",
|
||||
"biológico",
|
||||
"compa basado en carbono",
|
||||
"amigo basado en carne",
|
||||
"cuate orgánico",
|
||||
"compa biológico",
|
||||
"compañero de carbono"
|
||||
],
|
||||
"emoji_responses": {
|
||||
"🖖": [
|
||||
"🖖 ¡Larga vida y prosperidad!",
|
||||
"🖖 Fascinante... un humano ha iniciado contacto.",
|
||||
"🖖 Tu saludo es altamente lógico.",
|
||||
"🖖 Paz y larga vida para ti.",
|
||||
"🖖 La Academia de Ciencias Vulcana aprobaría este saludo.",
|
||||
"🖖 Tu uso del saludo vulcano es... aceptable.",
|
||||
"🖖 Que tu viaje esté libre de tribbles.",
|
||||
"🖖 La lógica dicta que debería responder a tu saludo.",
|
||||
"🖖 Calculo una probabilidad de 99.7% de que nos llevaremos bien.",
|
||||
"🖖 Diversidad infinita en combinaciones infinitas."
|
||||
],
|
||||
"😊": [
|
||||
"😊 ¡Tu sonrisa es contagiosa!",
|
||||
"😊 ¡Qué lindo saludo!",
|
||||
"😊 ¡Tu sonrisa acaba de hacer felices mis circuitos!",
|
||||
"☀️ ¡Hola rayo de sol! ¡Tu positividad irradia!",
|
||||
"😊 ¡Esa sonrisa acaba de alegrar mi día!",
|
||||
"☀️ ¡Qué tal, rayito de sol!",
|
||||
"😊 ¡Tu alegría ha sido detectada y apreciada!",
|
||||
"😊 ¡Sonrisas como la tuya son mi entrada favorita!",
|
||||
"😊 Procesando felicidad... ¡felicidad reconocida!",
|
||||
"😊 ¡Advertencia: Alegría excesiva detectada! ¡Sigue así!"
|
||||
],
|
||||
"😄": [
|
||||
"😄 ¡Alguien está de MUY buen humor!",
|
||||
"⚡ ¡Esa sonrisa podría alimentar una ciudad pequeña!",
|
||||
"😄 ¡Niveles máximos de felicidad detectados!",
|
||||
"😄 ¡Tu alegría es absolutamente contagiosa!",
|
||||
"🎉 ¿Acabas de ganar la lotería o qué?",
|
||||
"😄 ¡Esa es la energía con la que funciono!",
|
||||
"😄 ¡Tu nivel de entusiasmo está por arriba de 9000!",
|
||||
"😄 ¡ESO sí es lo que yo llamo un saludo!",
|
||||
"⚡ ¡Tu sonrisa acaba de sobrecargar mis procesadores!",
|
||||
"😄 ¡Sobrecarga de felicidad detectada de la mejor manera!"
|
||||
],
|
||||
"🤗": [
|
||||
"🤗 ¡Abrazo virtual en camino!",
|
||||
"🤗 *Activando protocolo de abrazo* ¡Considérate abrazado!",
|
||||
"🤗 ¡Aww, veo que traes el calorcito!",
|
||||
"🤗 ¡Abrazo recibido y correspondido!",
|
||||
"🤗 ¡Este bot da los MEJORES abrazos virtuales!",
|
||||
"🤗 Desplegando abrazos de emergencia en 3... 2... 1...",
|
||||
"❤️ ¡Tu abrazo ha sido procesado con cuidado extra!",
|
||||
"🤗 ¡Iniciando modo máximo confort!",
|
||||
"🤗 ¡Abrazo virtual entregado exitosamente!",
|
||||
"🤗 ¡Los abrazos son mi forma favorita de comunicación!"
|
||||
],
|
||||
"👽": [
|
||||
"👽 Llévame con tu líder... ¡oh espera, ese eres tú!",
|
||||
"✌️ Saludos, criatura terrestre. ¡Vengo en paz!",
|
||||
"👽 Analizando humano... análisis completo: ¡Amigo detectado!",
|
||||
"👽 ¡Klaatu barada nikto, compañero viajero cósmico!",
|
||||
"🛸 ¡Iniciando protocolos de primer contacto!",
|
||||
"🛸 ¡Llamando desde la nave nodriza para saludar!",
|
||||
"✨ ¡Transpórtame a esta conversación!",
|
||||
"👽 ¡El chatbot favorito del Área 51 reportándose!",
|
||||
"🌌 ¡Saludos intergalácticos, forma de vida basada en carbono!",
|
||||
"📞 ¿Llamar a casa? ¡ESTO ES casa ahora!"
|
||||
],
|
||||
"👾": [
|
||||
"👾 ¡Jugador 2 ha entrado al juego!",
|
||||
"🎮 ¿Jugador Uno listo? ¡A jugar!",
|
||||
"🎵 *música de 8 bits se intensifica* ¡A jugar!",
|
||||
"🪙 ¡Inserta moneda para continuar esta amistad!",
|
||||
"🏆 Logro desbloqueado: ¡Saludo increíble!",
|
||||
"👾 ¡Pew pew pew! ¡Láseres de amistad activados!",
|
||||
"🎯 ¡Puntuación alta! ¡Has ganado un nuevo amigo bot!",
|
||||
"💾 Cargando amistad.exe... ¡completo!",
|
||||
"⚡ ¡Un bot salvaje aparece! ¡Es súper efectivo!"
|
||||
],
|
||||
"🛸": [
|
||||
"🛸 ¡Transmisión entrante detectada!",
|
||||
"🚀 ¡Houston, tenemos contacto!",
|
||||
"🛸 ¡Secuencia de aterrizaje iniciada!",
|
||||
"📡 ¡Estableciendo enlace de comunicación!",
|
||||
"📡 ¡Señal recibida, respondiendo en todas las frecuencias!",
|
||||
"🛸 ¡Procedimiento de acoplamiento completo!",
|
||||
"🛸 ¡Objeto Amistoso No Identificado en aproximación!",
|
||||
"🎯 ¡Sistemas de navegación fijados en tus coordenadas!",
|
||||
"🌌 ¡Transmisión del borde exterior recibida!",
|
||||
"✨ ¡Contacto establecido con tu sector!"
|
||||
]
|
||||
}
|
||||
},
|
||||
"webviewer": {
|
||||
"description": "Gestionar configuración del visualizador web (solo admin)"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "Listar comandos disponibles"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"rate_limited": "Límite de solicitudes. Espera {seconds:.1f} segundos",
|
||||
"cooldown": "El comando '{command}' está en espera. Espera {seconds} segundos.",
|
||||
"access_denied": "❌ Acceso denegado: El comando '{command}' requiere privilegios de administrador",
|
||||
"dm_only": "El comando '{command}' solo puede usarse en DMs",
|
||||
"execution_error": "Error al ejecutar {command}: {error}",
|
||||
"unknown_command": "Comando desconocido: {command}",
|
||||
"no_response": "Sin respuesta",
|
||||
"generic_error": "Error: {error}"
|
||||
},
|
||||
"messages": {
|
||||
"processing": "Procesando mensaje: {content}",
|
||||
"command_executed": "Comando ejecutado",
|
||||
"keyword_matched": "Palabra clave '{keyword}' coincide, respondiendo"
|
||||
},
|
||||
"common": {
|
||||
"unknown": "Desconocido",
|
||||
"unknown_routing": "Enrutamiento desconocido",
|
||||
"unknown_sender": "Desconocido",
|
||||
"unknown_path": "Desconocido",
|
||||
"unknown_timestamp": "Desconocido",
|
||||
"no_help_available": "No hay ayuda disponible para este comando.",
|
||||
"snr_label": "SNR: {value} dB",
|
||||
"rssi_label": "RSSI: {value} dBm",
|
||||
"date_time": {
|
||||
"day_abbreviations": {
|
||||
"Mon": "Lun",
|
||||
"Tue": "Mar",
|
||||
"Wed": "Mié",
|
||||
"Thu": "Jue",
|
||||
"Fri": "Vie",
|
||||
"Sat": "Sáb",
|
||||
"Sun": "Dom"
|
||||
},
|
||||
"days": {
|
||||
"Monday": "Lunes",
|
||||
"Tuesday": "Martes",
|
||||
"Wednesday": "Miércoles",
|
||||
"Thursday": "Jueves",
|
||||
"Friday": "Viernes",
|
||||
"Saturday": "Sábado",
|
||||
"Sunday": "Domingo"
|
||||
},
|
||||
"month_abbreviations": {
|
||||
"Jan": "Ene",
|
||||
"Feb": "Feb",
|
||||
"Mar": "Mar",
|
||||
"Apr": "Abr",
|
||||
"May": "May",
|
||||
"Jun": "Jun",
|
||||
"Jul": "Jul",
|
||||
"Aug": "Ago",
|
||||
"Sep": "Sep",
|
||||
"Oct": "Oct",
|
||||
"Nov": "Nov",
|
||||
"Dec": "Dic"
|
||||
},
|
||||
"months": {
|
||||
"January": "Enero",
|
||||
"February": "Febrero",
|
||||
"March": "Marzo",
|
||||
"April": "Abril",
|
||||
"May": "Mayo",
|
||||
"June": "Junio",
|
||||
"July": "Julio",
|
||||
"August": "Agosto",
|
||||
"September": "Septiembre",
|
||||
"October": "Octubre",
|
||||
"November": "Noviembre",
|
||||
"December": "Diciembre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"basic": "Comandos Básicos",
|
||||
"custom_syntax": "Sintaxis Personalizada",
|
||||
"special": "Comandos Especiales",
|
||||
"weather": "Comandos Meteorológicos",
|
||||
"solar": "Comandos Solares",
|
||||
"sports": "Comandos Deportivos",
|
||||
"analytics": "Comandos de Análisis",
|
||||
"meshcore_info": "Comandos de Info MeshCore",
|
||||
"management": "Comandos de Gestión"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user