Merge pull request #13 from agessaman/dev-localization

Localization and some international functionality improvements.
This commit is contained in:
Adam Gessaman authored and GitHub committed 2025-11-21 19:27:06 -08:00
commit aa40a1dd23
42 files changed
+11774 -531

No files matched your search

+22 -3
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
+356 -73
View File
@@ -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
+61
View File
@@ -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:
+14 -4
View File
@@ -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
+24 -22
View File
@@ -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"""
+4 -4
View File
@@ -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
+21 -12
View File
@@ -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)"""
+76 -17
View File
@@ -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)
+9 -18
View File
@@ -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:
+3 -3
View File
@@ -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')
+47 -10
View File
@@ -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"""
+391
View File
@@ -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
+28 -42
View File
@@ -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)"""
+1 -1
View File
@@ -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"""
+25 -42
View File
@@ -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')
+3 -3
View File
@@ -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
+10 -10
View File
@@ -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')
+2 -2
View File
@@ -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
+51 -36
View File
@@ -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)
+12 -12
View File
@@ -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)"""
+29 -23
View File
@@ -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"""
+3 -3
View File
@@ -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')
+374 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+32 -3
View File
@@ -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
View File
@@ -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}")
+113
View File
@@ -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
+45 -2
View File
@@ -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')
+46
View File
@@ -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:
+1049
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+807
View File
@@ -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"
}
}
+901
View File
@@ -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
+1020
View File
File diff suppressed because it is too large. Load diff
+1029
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1025
View File
File diff suppressed because it is too large. Load diff