From e9e995167bbfdb6fed2ffc0496a4b2644af50403 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 29 Mar 2025 18:03:55 -0400 Subject: [PATCH] Weather plugin using open-meteo and geopy. --- Weather/README.md | 76 ++++++++ Weather/__init__.py | 37 ++++ Weather/config.py | 50 +++++ Weather/plugin.py | 455 ++++++++++++++++++++++++++++++++++++++++++++ Weather/test.py | 30 +++ 5 files changed, 648 insertions(+) create mode 100644 Weather/README.md create mode 100644 Weather/__init__.py create mode 100644 Weather/config.py create mode 100644 Weather/plugin.py create mode 100644 Weather/test.py diff --git a/Weather/README.md b/Weather/README.md new file mode 100644 index 0000000..2c3818a --- /dev/null +++ b/Weather/README.md @@ -0,0 +1,76 @@ +# Weather Plugin for Limnoria + +A Limnoria plugin that provides weather information using the Open-Meteo API and geolocation with Geopy. + +## Features + +- Get current weather conditions and forecasts +- Save locations for users +- Convert between temperature units +- Display weather for any location or your saved location +- Configurable display options + +## Dependencies + +This plugin depends on: +- `requests` - For making HTTP requests to the Open-Meteo API +- `geopy` - For geocoding location names to latitude/longitude + +Install these with your package manager or pip: +``` +pip install requests geopy +``` + +## Commands + +### User Commands + +- `.setweather ` - Set your location for weather lookups +- `.delweather` - Delete your saved location +- `.weather []` - Show weather for your saved location or for the specified location + +### Examples + +``` +.setweather Salt Lake City, Utah +.setweather 07644 +.setweather BLF +.weather +.weather Bluefield, WV +.weather Leichlingen +``` + +## Configuration + +The plugin has several configuration options: + +- `supybot.plugins.Weather.userAgent` - User agent for Nominatim geocoding service +- `supybot.plugins.Weather.defaultUnit` - Default temperature unit (C or F) +- `supybot.plugins.Weather.showHumidity` - Whether to show humidity in weather reports +- `supybot.plugins.Weather.showWind` - Whether to show wind speed in weather reports +- `supybot.plugins.Weather.showForecast` - Whether to show forecast in weather reports +- `supybot.plugins.Weather.forecastDays` - Number of forecast days to display + +These can be set using the `.config` command, for example: + +``` +.config channel #channel supybot.plugins.Weather.defaultUnit F +.config channel #channel supybot.plugins.Weather.showForecast True +.config channel #channel supybot.plugins.Weather.forecastDays 3 +``` + +## Installation + +1. Copy the plugin directory to your Limnoria plugins directory +2. Load the plugin: `.load Weather` +3. Configure as needed + +## Notes + +- Users must be registered and identified with the bot to use the `.setweather` and `.delweather` commands +- This plugin uses the free Open-Meteo API for weather data +- Location geocoding uses OpenStreetMap's Nominatim service, so please respect their usage policy + +## Privacy + +This plugin stores user locations in a local database file. Users can delete their data at any time using the `.delweather` command. diff --git a/Weather/__init__.py b/Weather/__init__.py new file mode 100644 index 0000000..ab425b8 --- /dev/null +++ b/Weather/__init__.py @@ -0,0 +1,37 @@ +### +# Copyright (c) 2025, Stormux +# +# This plugin is licensed under the same terms as Limnoria itself. +### + +""" +Weather: Get weather forecasts using the Open-Meteo API with geolocation by geopy +""" + +import sys +import supybot +from supybot import world + +__version__ = "2025.03.29" + +__author__ = supybot.Author("Storm Dragon", "storm_dragon@stormux.org", "") + +__contributors__ = {} + +__url__ = 'https://git.stormux.org/storm/limnoria-plugins' + +from . import config +from . import plugin +from importlib import reload +# If being reloaded +reload(config) +reload(plugin) + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/Weather/config.py b/Weather/config.py new file mode 100644 index 0000000..e9172c1 --- /dev/null +++ b/Weather/config.py @@ -0,0 +1,50 @@ +### +# Copyright (c) 2025, Your Name +# +# This plugin is licensed under the same terms as Limnoria itself. +### + +import supybot.conf as conf +import supybot.registry as registry +from supybot.i18n import PluginInternationalization, internationalizeDocstring + +_ = PluginInternationalization('Weather') + +def configure(advanced): + # This will be called by supybot to configure this module. + # advanced is a bool that specifies whether the user identified themself as + # an advanced user or not. You should effect your configuration by + # manipulating the registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Weather', True) + + +Weather = conf.registerPlugin('Weather') +# This is where your configuration variables (if any) should go. + +# User agent for Nominatim +conf.registerGlobalValue(Weather, 'userAgent', + registry.String('limnoria-weather-plugin/1.0', _("""User agent for Nominatim"""))) + +# Temperature unit +class WeatherConfig(registry.OnlySomeStrings): + validStrings = ('C', 'F') + +conf.registerChannelValue(Weather, 'defaultUnit', + WeatherConfig('F', _("""Default temperature unit (C or F)"""))) + +# Display options +conf.registerChannelValue(Weather, 'showHumidity', + registry.Boolean(True, _("""Show humidity in weather reports"""))) + +conf.registerChannelValue(Weather, 'showWind', + registry.Boolean(True, _("""Show wind speed in weather reports"""))) + +conf.registerChannelValue(Weather, 'showForecast', + registry.Boolean(True, _("""Show forecast in weather reports"""))) + +conf.registerChannelValue(Weather, 'forecastDays', + registry.PositiveInteger(3, _("""Number of forecast days to display"""))) + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/Weather/plugin.py b/Weather/plugin.py new file mode 100644 index 0000000..1561320 --- /dev/null +++ b/Weather/plugin.py @@ -0,0 +1,455 @@ +### +# Copyright (c) 2025, Your Name +# +# This plugin is licensed under the same terms as Limnoria itself. +### + +import supybot.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +import supybot.ircmsgs as ircmsgs +import supybot.conf as conf +import supybot.registry as registry +import supybot.ircdb as ircdb +import supybot.log as log +import requests +import datetime +import geopy.geocoders +from geopy.geocoders import Nominatim +from geopy.exc import GeocoderTimedOut, GeocoderServiceError +# For sending the version with geo api request +from . import __version__ + +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('Weather') +except ImportError: + # Placeholder that allows to run the plugin on a bot without the i18n module + _ = lambda x: x + + +class WeatherDB: + """Class to handle user location storage and retrieval""" + + pluginVersion = "0.1" + def __init__(self, filename): + self.filename = filename + self.db = {} + # Cache for geocoded locations + self.location_cache = {} + self.load() + + def load(self): + """Load the database from file""" + try: + with open(self.filename, 'r') as f: + for line in f: + line = line.strip() + if line: + if line.startswith('CACHE::'): + # This is a cached location + _, location_key, location_data = line.split('::', 2) + location, lat, lon = location_data.split('||') + self.location_cache[location_key.lower()] = { + 'location': location, + 'lat': lat, + 'lon': lon + } + else: + # User's saved location + (user, location, lat, lon) = line.split('::') + self.db[user] = {'location': location, 'lat': lat, 'lon': lon} + except IOError: + # File doesn't exist or can't be read + pass + + def save(self): + """Save the database to file""" + with open(self.filename, 'w') as f: + # Save user locations + for user, data in self.db.items(): + f.write(f"{user}::{data['location']}::{data['lat']}::{data['lon']}\n") + + # Save cached locations + for key, data in self.location_cache.items(): + f.write(f"CACHE::{key}::{data['location']}||{data['lat']}||{data['lon']}\n") + + def set(self, user, location, lat, lon): + """Set a user's location""" + self.db[user] = {'location': location, 'lat': lat, 'lon': lon} + self.save() + + def get(self, user): + """Get a user's location""" + return self.db.get(user, None) + + def remove(self, user): + """Remove a user's location""" + if user in self.db: + del self.db[user] + self.save() + + def add_to_cache(self, query, location, lat, lon): + """Add a location to the cache""" + query_key = query.lower() + self.location_cache[query_key] = { + 'location': location, + 'lat': lat, + 'lon': lon + } + self.save() + + def get_from_cache(self, query): + """Get a location from the cache""" + query_key = query.lower() + return self.location_cache.get(query_key, None) + + +class WeatherConfig(registry.OnlySomeStrings): + validStrings = ('C', 'F') + +class Configure(registry.Group): + def configure(self, advanced): + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Weather', True) + + if advanced: + # User agent for Nominatim + user_agent = something("used for Nominatim?", + default=f"stormux-limnoria-weather-plugin/{__version__}") + self.userAgent.setValue(user_agent) + + +Config = conf.registerPlugin('Weather') +conf.registerGlobalValue(Config, 'userAgent', + registry.String(f'stormux-limnoria-weather-plugin/{__version__}', _("""User agent for Nominatim"""))) +conf.registerChannelValue(Config, 'defaultUnit', + WeatherConfig('F', _("""Default temperature unit (C or F)"""))) +conf.registerChannelValue(Config, 'showHumidity', + registry.Boolean(True, _("""Show humidity in weather reports"""))) +conf.registerChannelValue(Config, 'showWind', + registry.Boolean(True, _("""Show wind speed in weather reports"""))) +conf.registerChannelValue(Config, 'showForecast', + registry.Boolean(True, _("""Show forecast in weather reports"""))) +conf.registerChannelValue(Config, 'forecastDays', + registry.PositiveInteger(3, _("""Number of forecast days to display"""))) + + +class Weather(callbacks.Plugin): + """Get weather forecasts using the Open-Meteo API""" + + def __init__(self, irc): + self.__parent = super(Weather, self) + self.__parent.__init__(irc) + + # Initialize weather codes (mapping from Open-Meteo codes to conditions) + self.weather_codes = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow fall", + 73: "Moderate snow fall", + 75: "Heavy snow fall", + 77: "Snow flurries", + 80: "Slight rain showers", + 81: "Moderate rain showers", + 82: "Heavy rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with slight hail", + 99: "Thunderstorm with heavy hail" + } + + # Initialize database + self.db = WeatherDB(conf.supybot.directories.data.dirize('Weather.db')) + + # Initialize geocoder + user_agent = self.registryValue('userAgent') + self.geocoder = Nominatim(user_agent=user_agent) + + # Set up logging + self.log = log.getPluginLogger('Weather') + + def _celsius_to_fahrenheit(self, celsius): + """Convert Celsius to Fahrenheit""" + if celsius is None: + return None + return (celsius * 9/5) + 32 + + def _format_temp(self, temp, unit='F'): + """Format temperature with unit""" + if temp is None: + return "N/A" + return f"{temp:.1f}°{unit}" + + def _kmh_to_mph(self, kmh): + """Convert km/h to mph""" + if kmh is None: + return None + return kmh * 0.621371 + + def _format_wind(self, wind_speed, unit='mph'): + """Format wind speed with unit""" + if wind_speed is None: + return "N/A" + return f"{wind_speed:.1f} {unit}" + + def _geocode(self, location): + """Geocode a location string to get latitude and longitude""" + cached_location = self.db.get_from_cache(location) + if cached_location: + self.log.info(f"Using cached geocoding for: {location}") + return { + 'lat': cached_location['lat'], + 'lon': cached_location['lon'], + 'address': cached_location['location'] + } + + # If not in cache, query the geocoding service + try: + self.log.info(f"Geocoding new location: {location}") + location_data = self.geocoder.geocode(location) + if location_data: + # Add to our cache for future use + self.db.add_to_cache( + location, + location_data.address, + location_data.latitude, + location_data.longitude + ) + + return { + 'lat': location_data.latitude, + 'lon': location_data.longitude, + 'address': location_data.address + } + return None + except (GeocoderTimedOut, GeocoderServiceError) as e: + self.log.error(f"Geocoding error: {e}") + return None + + def _get_weather(self, lat, lon, unit='F', forecast_days=3): + """Get weather data from Open-Meteo API""" + url = f"https://api.open-meteo.com/v1/forecast" + params = { + 'latitude': lat, + 'longitude': lon, + 'current': 'temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m', + 'daily': 'weather_code,temperature_2m_max,temperature_2m_min', + 'timezone': 'auto', + 'forecast_days': forecast_days + } + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + # Process current weather + current = data.get('current', {}) + temp_c = current.get('temperature_2m') + temp_f = self._celsius_to_fahrenheit(temp_c) if temp_c is not None else None + temp = temp_f if unit == 'F' else temp_c + + humidity = current.get('relative_humidity_2m') + + wind_kmh = current.get('wind_speed_10m') + wind_mph = self._kmh_to_mph(wind_kmh) if wind_kmh is not None else None + wind = wind_mph if unit == 'F' else wind_kmh + wind_unit = 'mph' if unit == 'F' else 'km/h' + + weather_code = current.get('weather_code', 0) + conditions = self.weather_codes.get(weather_code, "Unknown") + + # Process forecast + forecast = [] + daily = data.get('daily', {}) + dates = daily.get('time', []) + min_temps_c = daily.get('temperature_2m_min', []) + max_temps_c = daily.get('temperature_2m_max', []) + codes = daily.get('weather_code', []) + + for i in range(min(len(dates), forecast_days)): + date_str = dates[i] + min_temp_c = min_temps_c[i] if i < len(min_temps_c) else None + max_temp_c = max_temps_c[i] if i < len(max_temps_c) else None + code = codes[i] if i < len(codes) else 0 + + min_temp_f = self._celsius_to_fahrenheit(min_temp_c) if min_temp_c is not None else None + max_temp_f = self._celsius_to_fahrenheit(max_temp_c) if max_temp_c is not None else None + + min_temp = min_temp_f if unit == 'F' else min_temp_c + max_temp = max_temp_f if unit == 'F' else max_temp_c + + # Format date to day of week + try: + date_obj = datetime.datetime.strptime(date_str, '%Y-%m-%d') + day = date_obj.strftime('%A') + except ValueError: + day = date_str + + forecast.append({ + 'day': day, + 'date': date_str, + 'min_temp': min_temp, + 'max_temp': max_temp, + 'conditions': self.weather_codes.get(code, "Unknown") + }) + + return { + 'current': { + 'temp': temp, + 'humidity': humidity, + 'wind': wind, + 'wind_unit': wind_unit, + 'conditions': conditions + }, + 'forecast': forecast, + 'unit': unit + } + + except requests.RequestException as e: + self.log.error(f"Weather API error: {e}") + return None + + def setweather(self, irc, msg, args, location): + """ + + Set your location for weather lookups. Location can be a city name, ZIP code, or any location string. + """ + # Improved identification check + try: + user = ircdb.users.getUser(msg.prefix) + # User is identified if we got this far + except KeyError: + irc.error("You must be identified to use this command. Register with NickServ and identify yourself.", Raise=True) + + # Get the user's name + username = msg.prefix.split('!')[0] + + # Geocode the location + location_data = self._geocode(location) + if not location_data: + irc.error("Could not find that location. Please try a different search term.", Raise=True) + + # Store the user's location + self.db.set(username, location_data['address'], location_data['lat'], location_data['lon']) + + irc.reply(f"Your location has been set to: {location_data['address']}") + + setweather = wrap(setweather, ['text']) + + def delweather(self, irc, msg, args): + """takes no arguments + + Delete your saved location. + """ + # Improved identification check + try: + user = ircdb.users.getUser(msg.prefix) + # User is identified if we got this far + except KeyError: + irc.error("You must be identified to use this command. Register with NickServ and identify yourself.", Raise=True) + + # Get the user's name + username = msg.prefix.split('!')[0] + + # Remove the user's location + self.db.remove(username) + + irc.reply("Your location has been deleted.") + + delweather = wrap(delweather) + + def weather(self, irc, msg, args, location): + """[] + + Show weather for your saved location, or for the specified location. + """ + channel = msg.args[0] if irc.isChannel(msg.args[0]) else None + unit = self.registryValue('defaultUnit', channel) + show_humidity = self.registryValue('showHumidity', channel) + show_wind = self.registryValue('showWind', channel) + show_forecast = self.registryValue('showForecast', channel) + forecast_days = self.registryValue('forecastDays', channel) + + # Get the user's name + user = msg.prefix.split('!')[0] + + # Determine if showing weather for a location or the user's saved location + if location: + # Looking up weather for the specified location + location_data = self._geocode(location) + if not location_data: + irc.error("Could not find that location. Please try a different search term.", Raise=True) + + lat = location_data['lat'] + lon = location_data['lon'] + location_name = location_data['address'] + weather_for = location_name + else: + # Looking up the sender's weather from their saved location + user_data = self.db.get(user) + if not user_data: + irc.error("You have not set a location. Use '.weather ' to check the weather for a specific location, or '.setweather ' to save your location if you're registered.", Raise=True) + + lat = user_data['lat'] + lon = user_data['lon'] + location_name = user_data['location'] + weather_for = f"your location ({location_name})" + + # Get the weather data + weather_data = self._get_weather(lat, lon, unit, forecast_days) + if not weather_data: + irc.error("Could not retrieve weather data. Please try again later.", Raise=True) + + # Format the current weather + current = weather_data['current'] + temp = self._format_temp(current['temp'], unit) + conditions = current['conditions'] + + reply = f"Weather for {weather_for}: {temp}, {conditions}" + + if show_humidity and current['humidity'] is not None: + reply += f", Humidity: {current['humidity']}%" + + if show_wind and current['wind'] is not None: + reply += f", Wind: {self._format_wind(current['wind'], current['wind_unit'])}" + + # Send the current weather + irc.reply(reply) + + # Format and send the forecast if requested + if show_forecast and weather_data['forecast']: + forecast_parts = [] + for day in weather_data['forecast']: + min_temp = self._format_temp(day['min_temp'], unit) + max_temp = self._format_temp(day['max_temp'], unit) + forecast_parts.append(f"{day['day']}: {min_temp} to {max_temp}, {day['conditions']}") + + forecast_msg = f"Forecast: {' | '.join(forecast_parts)}" + irc.reply(forecast_msg) + + weather = wrap(weather, [optional('text')]) + + +Class = Weather + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/Weather/test.py b/Weather/test.py new file mode 100644 index 0000000..eeb7ce0 --- /dev/null +++ b/Weather/test.py @@ -0,0 +1,30 @@ +### +# Copyright (c) 2025, Stormux +# +# This plugin is licensed under the same terms as Limnoria itself. +### + +from supybot.test import * + +class WeatherTestCase(PluginTestCase): + plugins = ('Weather',) + + def testWeatherCommands(self): + # Since we can't actually test the API calls in a unit test, + # we'll just test that the commands exist and respond + + # Test setweather command + self.assertNotError('setweather Bluefield, WV') + + # Test weather command with no arguments + # This should use the previously set location + self.assertNotError('weather') + + # Test weather command with a location + self.assertNotError('weather Salt Lake City, Utah') + + # Test delweather command + self.assertNotError('delweather') + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: