Add support for plugin overrides in configuration, allowing alternative plugin implementations. Enhanced weather display settings with configurable units for temperature, wind speed, and precipitation. Updated repeater_manager to improve contact removal logic with better error handling and logging.

This commit is contained in:
agessaman
2025-11-15 15:07:43 -08:00
parent a93ce069a7
commit ed7f5691c7
9 changed files with 1056 additions and 39 deletions
+34
View File
@@ -96,6 +96,21 @@ admin_pubkeys =
# These commands will only work for users in the admin_pubkeys list
admin_commands = repeater,webviewer
[Plugin_Overrides]
# Plugin Overrides - Use alternative plugin implementations
# Format: command_name = alternative_file_name
# The alternative_file_name should be the name of a Python file (without .py extension)
# in the modules/commands/alternatives/ directory
#
# Example: To use an alternative weather plugin for international users:
# wx = wx_international
#
# This will replace the default wx command with the plugin from
# modules/commands/alternatives/wx_international.py
#
# Note: The alternative plugin must have the same 'name' metadata as the command
# it's replacing, or the override will use the alternative plugin's name instead.
[Companion_Purge]
# Enable companion contact purging
# true: Purge inactive companions when contact list is full
@@ -300,6 +315,25 @@ max_prefix_range = 200
# Use 2-letter state abbreviation (e.g., WA, CA, NY, TX)
default_state = WA
# Default country for city name disambiguation (for international weather plugin)
# Use 2-letter country code (e.g., US, CA, GB, AU)
default_country = US
# Temperature unit for weather display
# Options: fahrenheit, celsius
# Default: fahrenheit
temperature_unit = fahrenheit
# Wind speed unit for weather display
# Options: mph, kmh, ms (meters per second)
# Default: mph
wind_speed_unit = mph
# Precipitation unit for weather display
# Options: inch, mm
# Default: inch
precipitation_unit = inch
[Solar_Config]
# URL timeout for external API calls (seconds)
url_timeout = 10
+99
View File
@@ -0,0 +1,99 @@
# Alternative Plugins Directory
This directory is for alternative implementations of bot commands that can replace or supplement the default plugins.
## Purpose
Some default plugins may not work well in all contexts. For example:
- The `wx` command uses NOAA data which is primarily for US locations
- International users may need weather data from different sources
- Regional variations may require different implementations
Alternative plugins allow you to swap out default plugins without modifying the core codebase.
## How It Works
### Automatic Replacement by Name
If an alternative plugin has the same `name` metadata as a default plugin, it will automatically replace the default plugin. For example:
- Default plugin: `modules/commands/wx_command.py` with `name = "wx"`
- Alternative plugin: `modules/commands/alternatives/wx_international.py` with `name = "wx"`
The alternative plugin will automatically replace the default one.
### Configuration-Based Overrides
You can explicitly configure which alternative plugin to use for a command by adding a `[Plugin_Overrides]` section to your `config.ini`:
```ini
[Plugin_Overrides]
wx = wx_international
```
This tells the bot to use `wx_international.py` from the alternatives directory to replace the `wx` command.
## Creating an Alternative Plugin
1. **Copy the structure**: Start with the default plugin as a reference
2. **Place in alternatives directory**: Save your plugin as `modules/commands/alternatives/your_plugin_name.py`
3. **Match the plugin name**: Set the `name` class attribute to match the command you want to replace:
```python
class WxInternationalCommand(BaseCommand):
name = "wx" # This will replace the default wx command
keywords = ['wx', 'weather', 'wxa', 'wxalert']
# ... rest of implementation
```
4. **Implement required methods**: Your plugin must inherit from `BaseCommand` and implement the `execute` method
5. **Test**: Restart the bot and verify your alternative plugin is loaded
## Example: International Weather Plugin
For international users who need weather data from sources other than NOAA:
1. Create `modules/commands/alternatives/wx_international.py`
2. Implement a weather command that uses international APIs (e.g., OpenWeatherMap, WeatherAPI.com)
3. Set `name = "wx"` to replace the default wx command
4. Optionally add to `config.ini`:
```ini
[Plugin_Overrides]
wx = wx_international
```
## Best Practices
1. **Keep the same interface**: Alternative plugins should maintain the same keywords and command interface as the default plugin for consistency
2. **Document differences**: Add comments explaining why this alternative is needed and what makes it different
3. **Test thoroughly**: Make sure your alternative plugin works correctly before deploying
4. **Version control**: Consider keeping alternative plugins in a separate repository or clearly marking them as local modifications
## Plugin Loading Order
1. Default plugins are loaded first from `modules/commands/`
2. Configuration-based overrides are applied (from `[Plugin_Overrides]` section)
3. Alternative plugins with matching names automatically replace defaults
4. Standalone alternative plugins (with unique names) are loaded as additional commands
## Troubleshooting
- **Plugin not loading**: Check that your file is in `modules/commands/alternatives/` and has a `.py` extension
- **Wrong plugin loaded**: Verify the `name` attribute matches the command you want to replace
- **Import errors**: Make sure your alternative plugin imports from the correct paths (use relative imports like `from ..base_command import BaseCommand`)
- **Check logs**: The bot logs will show which plugins are loaded and from where
## Inactive Plugins
The `inactive/` subdirectory is for storing alternative plugins you want to keep but not load. Plugins in this directory are completely ignored by the plugin loader.
- Use it to store backup versions, unused alternatives, or plugins you might switch to later
- To activate an inactive plugin, move it from `inactive/` back to the main `alternatives/` directory
- See `inactive/README.md` for more details
## Notes
- Alternative plugins are loaded after default plugins, so they take precedence
- If multiple alternative plugins have the same name, the last one loaded wins
- Configuration-based overrides take precedence over automatic name matching
- The default plugins remain unchanged - alternatives are additive/replacement only
- Plugins in the `inactive/` subdirectory are never loaded, even if configured
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""
Alternative plugins directory
Place alternative implementations of commands here to override default plugins.
See README.md in this directory for usage instructions.
"""
@@ -0,0 +1,58 @@
# Inactive Alternative Plugins
This directory is for storing alternative plugin implementations that you want to keep but **not load**.
## Purpose
Use this directory to:
- Store alternative plugins you're not currently using
- Keep backup versions of plugins
- Organize plugins you might want to switch to later
- Store example or template plugins
## How It Works
- **Plugins in this directory are ignored** by the plugin loader
- They will not be automatically loaded or discovered
- They will not replace default plugins
- They will not be loaded even if configured in `[Plugin_Overrides]`
## Usage Examples
### Storing an Inactive Plugin
If you have an alternative weather plugin (`wx_international.py`) that you're not currently using:
1. Move it to this directory:
```bash
mv modules/commands/alternatives/wx_international.py \
modules/commands/alternatives/inactive/wx_international.py
```
2. The plugin will no longer be loaded
### Activating an Inactive Plugin
To use a plugin from this directory:
1. Move it back to the parent `alternatives/` directory:
```bash
mv modules/commands/alternatives/inactive/wx_international.py \
modules/commands/alternatives/wx_international.py
```
2. Configure it in `config.ini` if needed:
```ini
[Plugin_Overrides]
wx = wx_international
```
3. Restart the bot
## Best Practices
- Use this directory to organize plugins you might want to switch between
- Keep documentation in plugin files explaining when/why to use them
- Consider versioning or dating plugin names if you keep multiple versions
- Example: `wx_international_v1.py`, `wx_international_v2.py`
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""
Inactive alternative plugins directory
Place alternative plugin implementations here that you want to keep but not load.
Plugins in this directory will be ignored by the plugin loader.
"""
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
"""
Global Weather command for the MeshCore Bot
Provides worldwide weather information using Open-Meteo API
"""
import re
import requests
from datetime import datetime
from geopy.geocoders import Nominatim
from ..base_command import BaseCommand
from ...models import MeshMessage
class GlobalWxCommand(BaseCommand):
"""Handles global weather commands with city/location support"""
# Plugin metadata
name = "gwx"
keywords = ['gwx', 'globalweather', 'gwxa']
description = "Get weather information for any global location (usage: gwx Tokyo)"
category = "weather"
cooldown_seconds = 5 # 5 second cooldown per user to prevent API abuse
# Error constants
ERROR_FETCHING_DATA = "Error fetching weather data"
NO_ALERTS = "No weather alerts available"
def __init__(self, bot):
super().__init__(bot)
self.url_timeout = 10 # seconds
# Per-user cooldown tracking
self.user_cooldowns = {} # user_id -> last_execution_time
# Get default state and country from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='WA')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
# Get unit preferences from config
self.temperature_unit = self.bot.config.get('Weather', 'temperature_unit', fallback='fahrenheit').lower()
self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph').lower()
self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch').lower()
# Validate units
if self.temperature_unit not in ['fahrenheit', 'celsius']:
self.logger.warning(f"Invalid temperature_unit '{self.temperature_unit}', using 'fahrenheit'")
self.temperature_unit = 'fahrenheit'
if self.wind_speed_unit not in ['mph', 'kmh', 'ms']:
self.logger.warning(f"Invalid wind_speed_unit '{self.wind_speed_unit}', using 'mph'")
self.wind_speed_unit = 'mph'
if self.precipitation_unit not in ['inch', 'mm']:
self.logger.warning(f"Invalid precipitation_unit '{self.precipitation_unit}', using 'inch'")
self.precipitation_unit = 'inch'
# Initialize geocoder
self.geolocator = Nominatim(user_agent="meshcore-bot")
# Get database manager for geocoding cache
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)"
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with a weather keyword"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
for keyword in self.keywords:
if content_lower.startswith(keyword + ' '):
return True
return False
def can_execute(self, message: MeshMessage) -> bool:
"""Override cooldown check to be per-user instead of per-command-instance"""
if self.requires_dm and not message.is_dm:
return False
# Check per-user cooldown
if self.cooldown_seconds > 0:
import time
current_time = time.time()
user_id = message.sender_id
if user_id in self.user_cooldowns:
last_execution = self.user_cooldowns[user_id]
if (current_time - last_execution) < self.cooldown_seconds:
return False
return True
def get_remaining_cooldown(self, user_id: str) -> int:
"""Get remaining cooldown time for a specific user"""
if self.cooldown_seconds <= 0:
return 0
import time
current_time = time.time()
if user_id in self.user_cooldowns:
last_execution = self.user_cooldowns[user_id]
elapsed = current_time - last_execution
remaining = self.cooldown_seconds - elapsed
return max(0, int(remaining))
return 0
def _record_execution(self, user_id: str):
"""Record the execution time for a specific user"""
import time
self.user_cooldowns[user_id] = time.time()
async def execute(self, message: MeshMessage) -> bool:
"""Execute the weather command"""
content = message.content.strip()
# Parse the command to extract location
parts = content.split(maxsplit=1)
if len(parts) < 2:
await self.send_response(message, "Usage: gwx <location> - Example: gwx Tokyo or gwx Paris, France")
return True
location = parts[1].strip()
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)
# Check if we need to send multiple messages (for alerts)
if isinstance(weather_data, tuple) and weather_data[0] == "multi_message":
# Send weather data first
await self.send_response(message, weather_data[1])
# Wait for bot TX rate limiter
import asyncio
rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
sleep_time = max(rate_limit + 1.0, 2.0)
await asyncio.sleep(sleep_time)
# Send alerts
await self.send_response(message, weather_data[2])
else:
await self.send_response(message, weather_data)
return True
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}")
return True
async def get_weather_for_location(self, location: str) -> str:
"""Get weather data for any global location"""
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}'"
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"
# Check for severe weather warnings (Open-Meteo doesn't provide detailed alerts,
# but we can infer from extreme conditions)
alert_text = self._check_extreme_conditions(weather_text)
if alert_text:
# Return multi-message format
return ("multi_message", f"{location_display}: {weather_text}", alert_text)
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}"
def geocode_location(self, location: str) -> tuple:
"""Convert location string to lat/lon with address details"""
try:
# Check cache first
cache_key = location.lower().strip()
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(cache_key)
if cached_lat is not None and cached_lon is not None:
self.logger.debug(f"Using cached geocoding for {location}")
# Get address details with reverse geocoding
try:
reverse_location = self.geolocator.reverse(f"{cached_lat}, {cached_lon}")
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Store the full geocode result for display name
return cached_lat, cached_lon, address_info, reverse_location
except Exception:
pass
return cached_lat, cached_lon, {}, None
# Try geocoding with different strategies
geocode_result = None
# Strategy 1: Try as-is
geocode_result = self.geolocator.geocode(location)
# Strategy 2: If no result and no country specified, try with default country
if not geocode_result and ',' not in location:
geocode_result = self.geolocator.geocode(f"{location}, {self.default_country}")
if not geocode_result:
return None, None, None, None
lat, lon = geocode_result.latitude, geocode_result.longitude
address_info = geocode_result.raw.get('address', {})
# Cache the result
self.db_manager.cache_geocoding(cache_key, lat, lon)
return lat, lon, address_info, geocode_result
except Exception as e:
self.logger.error(f"Error geocoding location {location}: {e}")
return None, None, None, None
def _format_location_display(self, address_info: dict, geocode_result, fallback: str) -> str:
"""Format location name for display from address info - returns 'City, CountryCode' format"""
# Get country code first (prefer this over full country name)
country_code = ''
if address_info:
country_code = address_info.get('country_code', '').upper()
# Try to get city name from address_info (this is more reliable than display_name)
city = None
if address_info:
# Try various address fields in order of preference
city = (address_info.get('city') or
address_info.get('town') or
address_info.get('village') or
address_info.get('municipality') or
address_info.get('city_district'))
# If we still don't have a city, try parsing from display_name
if not city and geocode_result and hasattr(geocode_result, 'raw'):
display_name = geocode_result.raw.get('display_name', '')
if display_name:
# Parse display_name - usually format is "Place, City, State/Province, Country"
# We want the city, not the specific place
parts = [p.strip() for p in display_name.split(',')]
# Skip the first part (specific location) and look for city in later parts
for i, part in enumerate(parts[1:], 1):
# Check if this part looks like a city (not a state/province or country)
if i < len(parts) - 1: # Not the last part (country)
city = part
break
# If still no city, try extracting from display_name first part (but clean it up)
if not city and geocode_result and hasattr(geocode_result, 'raw'):
display_name = geocode_result.raw.get('display_name', '')
if display_name:
parts = [p.strip() for p in display_name.split(',')]
if parts:
# Take first part but try to extract city name
first_part = parts[0]
# Remove common venue/location suffixes
for suffix in [' Terminal', ' Station', ' Airport', ' Hotel', ' Building',
' Plaza', ' Center', ' Centre', ' Park', ' Square']:
if suffix in first_part:
first_part = first_part.replace(suffix, '').strip()
city = first_part
# For US locations, include state abbreviation
if country_code == 'US':
state = None
if address_info:
state = address_info.get('state')
if city and state:
state_abbrev = self._get_state_abbreviation(state)
return f"{city}, {state_abbrev}"
elif city:
return f"{city}, US"
# For international locations, always use country code if available
if city:
if country_code:
return f"{city}, {country_code}"
elif address_info and address_info.get('country'):
# Fallback to country name if no code available
country = address_info.get('country')
# Shorten very long country names
if len(country) > 15:
return f"{city}, {country[:15]}"
return f"{city}, {country}"
else:
return city
# Final fallback: try to extract from input and capitalize
if fallback:
# Try to extract city name from input (before first comma if present)
parts = fallback.split(',')
city_part = parts[0].strip().title()
# Remove common suffixes
for suffix in [' Terminal', ' Station', ' Airport', ' Hotel', ' Building']:
if suffix in city_part:
city_part = city_part.replace(suffix, '').strip()
if country_code:
return f"{city_part}, {country_code}"
elif len(parts) > 1:
# Try to get country from input
country_part = parts[-1].strip()
return f"{city_part}, {country_part[:10]}" # Limit country name length
return city_part
return fallback.title()
def _get_state_abbreviation(self, state: str) -> str:
"""Convert full state name to abbreviation"""
state_map = {
'Washington': 'WA', 'California': 'CA', 'New York': 'NY', 'Texas': 'TX',
'Florida': 'FL', 'Illinois': 'IL', 'Pennsylvania': 'PA', 'Ohio': 'OH',
'Georgia': 'GA', 'North Carolina': 'NC', 'Michigan': 'MI', 'New Jersey': 'NJ',
'Virginia': 'VA', 'Tennessee': 'TN', 'Indiana': 'IN', 'Arizona': 'AZ',
'Massachusetts': 'MA', 'Missouri': 'MO', 'Maryland': 'MD', 'Wisconsin': 'WI',
'Colorado': 'CO', 'Minnesota': 'MN', 'South Carolina': 'SC', 'Alabama': 'AL',
'Louisiana': 'LA', 'Kentucky': 'KY', 'Oregon': 'OR', 'Oklahoma': 'OK',
'Connecticut': 'CT', 'Utah': 'UT', 'Iowa': 'IA', 'Nevada': 'NV',
'Arkansas': 'AR', 'Mississippi': 'MS', 'Kansas': 'KS', 'New Mexico': 'NM',
'Nebraska': 'NE', 'West Virginia': 'WV', 'Idaho': 'ID', 'Hawaii': 'HI',
'New Hampshire': 'NH', 'Maine': 'ME', 'Montana': 'MT', 'Rhode Island': 'RI',
'Delaware': 'DE', 'South Dakota': 'SD', 'North Dakota': 'ND', 'Alaska': 'AK',
'Vermont': 'VT', 'Wyoming': 'WY'
}
return state_map.get(state, state)
def get_open_meteo_weather(self, lat: float, lon: float) -> str:
"""Get weather forecast from Open-Meteo API"""
try:
# Open-Meteo API endpoint with current weather and forecast
api_url = "https://api.open-meteo.com/v1/forecast"
params = {
'latitude': lat,
'longitude': lon,
'current': 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,dewpoint_2m,visibility,surface_pressure',
'daily': 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max',
'hourly': 'temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m',
'temperature_unit': self.temperature_unit,
'wind_speed_unit': self.wind_speed_unit,
'precipitation_unit': self.precipitation_unit,
'timezone': 'auto',
'forecast_days': 2
}
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
data = response.json()
# Check units in response to verify API is respecting our unit requests
current_units = data.get('current_units', {})
temp_unit = current_units.get('temperature_2m', '°F')
visibility_unit = current_units.get('visibility', 'm')
# Extract current conditions
current = data.get('current', {})
daily = data.get('daily', {})
hourly = data.get('hourly', {})
# Current conditions - API should return in Fahrenheit when requested
temp = int(current.get('temperature_2m', 0))
feels_like = int(current.get('apparent_temperature', temp))
dewpoint = current.get('dewpoint_2m')
humidity = int(current.get('relative_humidity_2m', 0))
wind_speed = int(current.get('wind_speed_10m', 0))
wind_direction = self._degrees_to_direction(current.get('wind_direction_10m', 0))
wind_gusts = int(current.get('wind_gusts_10m', 0))
visibility = current.get('visibility')
pressure = current.get('surface_pressure')
weather_code = current.get('weather_code', 0)
# Convert visibility to miles based on actual unit from API
# API returns visibility in feet when using imperial units
if visibility is not None:
if visibility_unit == 'ft' or 'ft' in str(visibility_unit).lower():
# Convert from feet to miles (1 mile = 5280 feet)
visibility_mi = visibility / 5280.0
else:
# Assume meters, convert to miles (1 mile = 1609.34 meters)
visibility_mi = visibility / 1609.34
else:
visibility_mi = None
# Pressure validation - account for high elevation locations
# Normal sea level pressure is 1013 hPa, range is typically 950-1050 hPa
# At high elevations (e.g., 2500m), pressure can be 750-800 hPa, which is normal
# Only filter out extremely low pressures (< 600 hPa) which would be invalid
if pressure is not None and pressure < 600:
self.logger.warning(f"Extremely low pressure value: {pressure} hPa - might be invalid")
pressure = None
# Get weather description and emoji
weather_desc = self._get_weather_description(weather_code)
weather_emoji = self._get_weather_emoji(weather_code)
# Determine temperature unit symbol
temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C"
# Determine if it's day or night for forecast period name
now = datetime.now()
hour = now.hour
if 6 <= hour < 18:
period_name = "Today"
else:
period_name = "Tonight"
# Build current weather string
weather = f"{period_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}"
# Add feels like if significantly different
if abs(feels_like - temp) >= 5:
weather += f" (feels {feels_like}{temp_symbol})"
# Add wind info (always show if >= 3 mph, show gusts if significant)
if wind_speed >= 3:
weather += f" {wind_direction}{wind_speed}"
if wind_gusts > wind_speed + 3:
weather += f"G{wind_gusts}"
# Add humidity
weather += f" {humidity}%RH"
# Add additional conditions if space allows
conditions = []
# Add dew point
if dewpoint is not None:
dewpoint_val = int(dewpoint)
conditions.append(f"💧{dewpoint_val}{temp_symbol}")
# Add visibility (already converted to miles above)
if visibility_mi is not None and visibility_mi > 0:
# Cap visibility at 20 miles for display (beyond that is essentially unlimited)
visibility_display = int(visibility_mi)
if visibility_display > 20:
visibility_display = 20
conditions.append(f"👁️{visibility_display}mi")
# Add pressure (convert from hPa to display format)
if pressure is not None:
pressure_hpa = int(pressure)
conditions.append(f"📊{pressure_hpa}hPa")
# Add conditions to weather string if space allows
if conditions and len(weather) < 120:
weather += " " + " ".join(conditions)
# Add forecast for today/tonight and tomorrow
# API should return temperatures in Fahrenheit when requested
if daily:
today_high = int(daily['temperature_2m_max'][0])
today_low = int(daily['temperature_2m_min'][0])
weather += f" | {period_name}: {today_high}{temp_symbol}/{today_low}{temp_symbol}"
# Add tomorrow if space allows (check length more carefully)
if len(daily['temperature_2m_max']) > 1:
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)
# Get tomorrow's period name
tomorrow_period = "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)
if len(weather + tomorrow_str) < 180: # Increased limit to prevent truncation
weather += tomorrow_str
# Add precipitation probability if significant and space allows
if len(daily.get('precipitation_probability_max', [])) > 1:
precip_prob = daily['precipitation_probability_max'][1]
if precip_prob >= 30:
precip_str = f" 🌦️{precip_prob}%"
if len(weather + precip_str) <= 200: # Reasonable message length limit
weather += precip_str
return weather
except Exception as e:
self.logger.error(f"Error fetching Open-Meteo weather: {e}")
return self.ERROR_FETCHING_DATA
def _degrees_to_direction(self, degrees: float) -> str:
"""Convert wind direction in degrees to compass direction with emoji"""
if degrees is None:
return ""
directions = [
(0, "⬆️N"), (22.5, "↗️NE"), (45, "↗️NE"), (67.5, "➡️E"),
(90, "➡️E"), (112.5, "↘️SE"), (135, "↘️SE"), (157.5, "⬇️S"),
(180, "⬇️S"), (202.5, "↙️SW"), (225, "↙️SW"), (247.5, "⬅️W"),
(270, "⬅️W"), (292.5, "↖️NW"), (315, "↖️NW"), (337.5, "⬆️N"),
(360, "⬆️N")
]
# Find closest direction
for i in range(len(directions) - 1):
if directions[i][0] <= degrees < directions[i + 1][0]:
return directions[i][1]
return "⬆️N" # Default to North
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"
}
return weather_codes.get(code, "Unknown")
def _get_weather_emoji(self, code: int) -> str:
"""Convert WMO weather code to emoji"""
emoji_map = {
0: "☀️", # Clear
1: "🌤️", # Mostly Clear
2: "", # Partly Cloudy
3: "☁️", # Overcast
45: "🌫️", # Fog
48: "🌫️", # Fog
51: "🌦️", # Drizzle
53: "🌦️", # Drizzle
55: "🌧️", # Heavy Drizzle
56: "🌧️", # Freezing Drizzle
57: "🌧️", # Freezing Drizzle
61: "🌧️", # Rain
63: "🌧️", # Rain
65: "🌧️", # Heavy Rain
66: "🌧️", # Freezing Rain
67: "🌧️", # Freezing Rain
71: "❄️", # Snow
73: "❄️", # Snow
75: "❄️", # Heavy Snow
77: "❄️", # Snow Grains
80: "🌦️", # Showers
81: "🌦️", # Showers
82: "🌧️", # Heavy Showers
85: "🌨️", # Snow Showers
86: "🌨️", # Snow Showers
95: "⛈️", # Thunderstorm
96: "⛈️", # Thunderstorm with Hail
99: "⛈️" # Severe Thunderstorm
}
return emoji_map.get(code, "🌤️")
def _check_extreme_conditions(self, weather_text: str) -> str:
"""Check for extreme weather conditions that warrant warnings"""
warnings = []
# Extract temperature from weather text
temp_match = re.search(r'(\d+)°F', weather_text)
if temp_match:
temp = int(temp_match.group(1))
if temp >= 95:
warnings.append("⚠️ Extreme heat")
elif temp <= 20:
warnings.append("⚠️ Extreme cold")
# Check for severe weather indicators
if "Heavy Rain" in weather_text or "Heavy Showers" in weather_text:
warnings.append("⚠️ Heavy rain")
if "Thunderstorm" in weather_text or "T-Storm" in weather_text:
warnings.append("⚠️ Thunderstorms")
if "Heavy Snow" in weather_text or "Snow Showers" in weather_text:
warnings.append("⚠️ 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)")
return " | ".join(warnings) if warnings else None
+19
View File
@@ -392,6 +392,25 @@ prefix_free_days = 30
# Use 2-letter state abbreviation (e.g., WA, CA, NY, TX)
default_state = WA
# Default country for city name disambiguation (for international weather plugin)
# Use 2-letter country code (e.g., US, CA, GB, AU)
default_country = US
# Temperature unit for weather display
# Options: fahrenheit, celsius
# Default: fahrenheit
temperature_unit = fahrenheit
# Wind speed unit for weather display
# Options: mph, kmh, ms (meters per second)
# Default: mph
wind_speed_unit = mph
# Precipitation unit for weather display
# Options: inch, mm
# Default: inch
precipitation_unit = inch
[Path_Command]
# Geographic proximity calculation method
# simple: Use proximity to bot location (default)
+154 -12
View File
@@ -22,10 +22,27 @@ class PluginLoader:
self.bot = bot
self.logger = bot.logger
self.commands_dir = commands_dir or os.path.join(os.path.dirname(__file__), 'commands')
self.alternatives_dir = os.path.join(self.commands_dir, 'alternatives')
self.loaded_plugins: Dict[str, BaseCommand] = {}
self.plugin_metadata: Dict[str, Dict[str, Any]] = {}
self.keyword_mappings: Dict[str, str] = {} # keyword -> plugin_name
self.plugin_overrides: Dict[str, str] = {} # plugin_name -> alternative_file_name
self._load_plugin_overrides()
def _load_plugin_overrides(self):
"""Load plugin override configuration from config file"""
self.plugin_overrides = {}
try:
if self.bot.config.has_section('Plugin_Overrides'):
for command_name, alternative_file in self.bot.config.items('Plugin_Overrides'):
# Remove .py extension if present
if alternative_file.endswith('.py'):
alternative_file = alternative_file[:-3]
self.plugin_overrides[command_name.strip()] = alternative_file.strip()
self.logger.info(f"Plugin override configured: {command_name} -> {alternative_file}")
except Exception as e:
self.logger.warning(f"Error loading plugin overrides: {e}")
def discover_plugins(self) -> List[str]:
"""Discover all Python files in the commands directory that could be plugins"""
plugin_files = []
@@ -43,11 +60,41 @@ class PluginLoader:
self.logger.info(f"Discovered {len(plugin_files)} potential plugin files: {plugin_files}")
return plugin_files
def load_plugin(self, plugin_name: str) -> Optional[BaseCommand]:
"""Load a single plugin by name"""
def discover_alternative_plugins(self) -> List[str]:
"""Discover all Python files in the alternatives directory that could be plugins
Note: Plugins in the 'inactive' subdirectory are ignored
"""
plugin_files = []
alternatives_path = Path(self.alternatives_dir)
if not alternatives_path.exists():
# Alternatives directory doesn't exist yet, that's okay
return plugin_files
# Scan for Python files (excluding __init__.py)
# Note: glob("*.py") only matches files in the current directory, not subdirectories
# So the 'inactive' subdirectory is automatically excluded
for file_path in alternatives_path.glob("*.py"):
if file_path.name not in ["__init__.py"]:
plugin_files.append(file_path.stem)
if plugin_files:
self.logger.info(f"Discovered {len(plugin_files)} alternative plugin files: {plugin_files}")
return plugin_files
def load_plugin(self, plugin_name: str, from_alternatives: bool = False) -> Optional[BaseCommand]:
"""Load a single plugin by name
Args:
plugin_name: Name of the plugin file (without .py extension)
from_alternatives: If True, load from alternatives directory; if False, load from commands directory
"""
try:
# Construct the full module path
module_path = f"modules.commands.{plugin_name}"
if from_alternatives:
module_path = f"modules.commands.alternatives.{plugin_name}"
else:
module_path = f"modules.commands.{plugin_name}"
# Check if module is already loaded
if module_path in sys.modules:
@@ -79,7 +126,8 @@ class PluginLoader:
metadata['name'] = command_class.__name__.lower().replace('command', '')
plugin_instance.name = metadata['name']
self.logger.info(f"Successfully loaded plugin: {metadata['name']} from {plugin_name}")
source = "alternatives" if from_alternatives else "default"
self.logger.info(f"Successfully loaded plugin: {metadata['name']} from {plugin_name} ({source})")
return plugin_instance
except Exception as e:
@@ -87,20 +135,86 @@ class PluginLoader:
return None
def load_all_plugins(self) -> Dict[str, BaseCommand]:
"""Load all discovered plugins"""
plugin_files = self.discover_plugins()
"""Load all discovered plugins, with alternative plugins taking priority when configured"""
# First, discover all default and alternative plugins
default_plugin_files = self.discover_plugins()
alternative_plugin_files = self.discover_alternative_plugins()
# Build a map of plugin names to their file names for default plugins
default_plugin_map = {} # plugin_name -> file_name
loaded_plugins = {}
for plugin_file in plugin_files:
plugin_instance = self.load_plugin(plugin_file)
# First pass: Load all default plugins and build the map
for plugin_file in default_plugin_files:
plugin_instance = self.load_plugin(plugin_file, from_alternatives=False)
if plugin_instance:
metadata = plugin_instance.get_metadata()
plugin_name = metadata['name']
default_plugin_map[plugin_name] = plugin_file
loaded_plugins[plugin_name] = plugin_instance
self.plugin_metadata[plugin_name] = metadata
# Second pass: Check for overrides and load alternative plugins
# Check config-based overrides first
for plugin_name, alternative_file in self.plugin_overrides.items():
if alternative_file in alternative_plugin_files:
# Load the alternative plugin
alt_instance = self.load_plugin(alternative_file, from_alternatives=True)
if alt_instance:
alt_metadata = alt_instance.get_metadata()
alt_plugin_name = alt_metadata['name']
# If the alternative plugin has a different name, log a warning
if alt_plugin_name != plugin_name:
self.logger.warning(
f"Alternative plugin {alternative_file} has name '{alt_plugin_name}' "
f"but is configured to override '{plugin_name}'. Using '{alt_plugin_name}'."
)
plugin_name = alt_plugin_name
# Replace the default plugin with the alternative
if plugin_name in loaded_plugins:
self.logger.info(f"Replacing default plugin '{plugin_name}' with alternative '{alternative_file}'")
loaded_plugins[plugin_name] = alt_instance
self.plugin_metadata[plugin_name] = alt_metadata
else:
self.logger.warning(f"Failed to load alternative plugin '{alternative_file}' for '{plugin_name}'")
else:
self.logger.warning(
f"Alternative plugin '{alternative_file}' not found in alternatives directory "
f"for override of '{plugin_name}'"
)
# Third pass: Load alternative plugins that aren't overriding anything
# (standalone alternative plugins)
for alt_file in alternative_plugin_files:
# Skip if this alternative is already loaded as an override
if alt_file in self.plugin_overrides.values():
continue
alt_instance = self.load_plugin(alt_file, from_alternatives=True)
if alt_instance:
alt_metadata = alt_instance.get_metadata()
alt_plugin_name = alt_metadata['name']
# Build keyword mappings
self._build_keyword_mappings(plugin_name, metadata)
# If an alternative plugin has the same name as a default plugin,
# it will replace it (unless already overridden by config)
if alt_plugin_name in loaded_plugins:
if alt_plugin_name not in self.plugin_overrides:
self.logger.info(
f"Alternative plugin '{alt_file}' replaces default plugin '{alt_plugin_name}' "
f"(same plugin name detected)"
)
else:
self.logger.info(f"Loading standalone alternative plugin: {alt_plugin_name} from {alt_file}")
loaded_plugins[alt_plugin_name] = alt_instance
self.plugin_metadata[alt_plugin_name] = alt_metadata
# Build keyword mappings for all loaded plugins
for plugin_name, plugin_instance in loaded_plugins.items():
metadata = self.plugin_metadata[plugin_name]
self._build_keyword_mappings(plugin_name, metadata)
self.loaded_plugins = loaded_plugins
self.logger.info(f"Loaded {len(loaded_plugins)} plugins: {list(loaded_plugins.keys())}")
@@ -164,8 +278,36 @@ class PluginLoader:
for keyword in keywords_to_remove:
del self.keyword_mappings[keyword]
# Reload the plugin
plugin_instance = self.load_plugin(plugin_name)
# Check if this plugin should be loaded from alternatives
from_alternatives = False
if plugin_name in self.plugin_overrides:
# This plugin is overridden, reload from alternatives
alternative_file = self.plugin_overrides[plugin_name]
plugin_instance = self.load_plugin(alternative_file, from_alternatives=True)
else:
# Try to find the plugin file name
# First check if it's in default plugins
default_plugins = self.discover_plugins()
plugin_file = None
for df in default_plugins:
test_instance = self.load_plugin(df, from_alternatives=False)
if test_instance and test_instance.get_metadata().get('name') == plugin_name:
plugin_file = df
break
if plugin_file:
plugin_instance = self.load_plugin(plugin_file, from_alternatives=False)
else:
# Try alternatives
alt_plugins = self.discover_alternative_plugins()
for alt in alt_plugins:
test_instance = self.load_plugin(alt, from_alternatives=True)
if test_instance and test_instance.get_metadata().get('name') == plugin_name:
plugin_instance = self.load_plugin(alt, from_alternatives=True)
break
else:
plugin_instance = None
if plugin_instance:
metadata = plugin_instance.get_metadata()
self.loaded_plugins[plugin_name] = plugin_instance
+51 -27
View File
@@ -2084,39 +2084,63 @@ class RepeaterManager:
self.logger.info(f"✅ Contact '{contact_name}' not found in device contacts (already removed) - treating as success")
device_removal_successful = True
else:
# Remove the contact using the documented MeshCore API: meshcore.remove_contact(key)
# Remove the contact using the MeshCore API: meshcore.commands.remove_contact(key)
try:
self.logger.info(f"Removing companion '{contact_name}' from device using meshcore.remove_contact()...")
self.logger.info(f"Removing companion '{contact_name}' from device using meshcore.commands.remove_contact()...")
self.logger.debug(f"Contact details: public_key={public_key}, contact_key={contact_key}, name='{contact_name}'")
# Check if the method exists
if not hasattr(self.bot.meshcore, 'remove_contact'):
self.logger.error(f"❌ meshcore.remove_contact() method not found on meshcore object")
# Check if the commands.remove_contact method exists
if not hasattr(self.bot.meshcore, 'commands') or not hasattr(self.bot.meshcore.commands, 'remove_contact'):
self.logger.error(f"❌ meshcore.commands.remove_contact() method not found on meshcore object")
device_removal_successful = False
else:
# Use the documented API: meshcore.remove_contact(key)
# Try with public_key first (most reliable identifier)
try:
self.logger.debug(f"Calling meshcore.remove_contact(public_key='{public_key[:16]}...')")
result = await asyncio.wait_for(
self.bot.meshcore.remove_contact(public_key),
timeout=30.0
)
# Check if result indicates success
# Result could be: True, EventType.OK, or an event object with .type
if result is True:
device_removal_successful = True
self.logger.info(f"✅ remove_contact returned True - removal successful")
elif hasattr(result, 'type') and result.type == EventType.OK:
device_removal_successful = True
self.logger.info(f"✅ remove_contact returned EventType.OK - removal successful")
else:
self.logger.warning(f"⚠️ remove_contact returned unexpected result: {result}")
device_removal_successful = False
# 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
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
except Exception as e:
self.logger.error(f"❌ meshcore.remove_contact() failed: {type(e).__name__}: {e}")
# Check if result indicates success
# Result could be: True, EventType.OK, or an event object with .type
if result is True:
device_removal_successful = True
self.logger.info(f"✅ remove_contact returned True - removal successful")
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:
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")
break
else:
self.logger.debug(f"remove_contact returned error_code {error_code}, trying next key...")
continue
else:
self.logger.debug(f"remove_contact returned unexpected result: {result}, trying next key...")
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:
self.logger.error(f"❌ No valid key available for remove_contact")
device_removal_successful = False
elif not device_removal_successful:
self.logger.error(f"❌ All remove_contact attempts failed")
device_removal_successful = False
except Exception as e: