From ed7f5691c7f23b8b9d4d4e5a87979787ffa2dfb5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 15 Nov 2025 15:07:43 -0800 Subject: [PATCH] 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. --- config.ini.example | 34 + modules/commands/alternatives/README.md | 99 +++ modules/commands/alternatives/__init__.py | 7 + .../commands/alternatives/inactive/README.md | 58 ++ .../alternatives/inactive/__init__.py | 7 + .../commands/alternatives/wx_international.py | 627 ++++++++++++++++++ modules/core.py | 19 + modules/plugin_loader.py | 166 ++++- modules/repeater_manager.py | 78 ++- 9 files changed, 1056 insertions(+), 39 deletions(-) create mode 100644 modules/commands/alternatives/README.md create mode 100644 modules/commands/alternatives/__init__.py create mode 100644 modules/commands/alternatives/inactive/README.md create mode 100644 modules/commands/alternatives/inactive/__init__.py create mode 100644 modules/commands/alternatives/wx_international.py diff --git a/config.ini.example b/config.ini.example index 0e6e340..c5a103e 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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 diff --git a/modules/commands/alternatives/README.md b/modules/commands/alternatives/README.md new file mode 100644 index 0000000..b579a5c --- /dev/null +++ b/modules/commands/alternatives/README.md @@ -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 + diff --git a/modules/commands/alternatives/__init__.py b/modules/commands/alternatives/__init__.py new file mode 100644 index 0000000..adb3408 --- /dev/null +++ b/modules/commands/alternatives/__init__.py @@ -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. +""" + diff --git a/modules/commands/alternatives/inactive/README.md b/modules/commands/alternatives/inactive/README.md new file mode 100644 index 0000000..0110d56 --- /dev/null +++ b/modules/commands/alternatives/inactive/README.md @@ -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` + diff --git a/modules/commands/alternatives/inactive/__init__.py b/modules/commands/alternatives/inactive/__init__.py new file mode 100644 index 0000000..7738c3d --- /dev/null +++ b/modules/commands/alternatives/inactive/__init__.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. +""" + diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py new file mode 100644 index 0000000..57a6467 --- /dev/null +++ b/modules/commands/alternatives/wx_international.py @@ -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 - 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 - 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 \ No newline at end of file diff --git a/modules/core.py b/modules/core.py index dfd64e1..3a72033 100644 --- a/modules/core.py +++ b/modules/core.py @@ -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) diff --git a/modules/plugin_loader.py b/modules/plugin_loader.py index 5495d83..0b70669 100644 --- a/modules/plugin_loader.py +++ b/modules/plugin_loader.py @@ -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 diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index fc6fb82..056a3bb 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -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: