diff --git a/config.default.yml b/config.default.yml index 22352b2ec..b165d62a4 100644 --- a/config.default.yml +++ b/config.default.yml @@ -41,7 +41,6 @@ api: giphy: google: news: - open_weather: openai: spotify_client: spotify_key: diff --git a/modules/utility/weather.py b/modules/utility/weather.py index a7125b6fe..e8a10cab4 100644 --- a/modules/utility/weather.py +++ b/modules/utility/weather.py @@ -2,11 +2,12 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Self import discord import munch -from discord.ext import commands +from discord import app_commands from core import auxiliary, cogs @@ -19,131 +20,249 @@ async def setup(bot: bot.TechSupportBot) -> None: Args: bot (bot.TechSupportBot): The bot object to register the cogs to - - Raises: - AttributeError: Raised if an API key is missing to prevent unusable commands from loading """ - - # Don't load without the API key - try: - if not bot.file_config.api.api_keys.open_weather: - raise AttributeError("Weather was not loaded due to missing API key") - except AttributeError as exc: - raise AttributeError("Weather was not loaded due to missing API key") from exc - await bot.add_cog(Weather(bot=bot)) class Weather(cogs.BaseCog): """Class to set up the weather extension for the discord bot.""" - def get_url(self: Self, args: list[str]) -> str: - """Generates the url to fill in API keys and data - - Args: - args (list[str]): The list of arguments passed by the user + async def preconfig(self: Self) -> None: + """Loads the wmo-map.json file as self.wmo_map""" + wmo_file = "resources/wmo-map.json" + with open(wmo_file, "r", encoding="utf-8") as file: + self.wmo_map = munch.munchify(json.load(file)) - Returns: - str: The API url formatted and ready to be called - """ - filtered_args = filter(bool, args) - searches = ",".join(map(str, filtered_args)) - url = "http://api.openweathermap.org/data/2.5/weather" - filled_url = ( - f"{url}?q={searches}&units=imperial&appid" - f"={self.bot.file_config.api.api_keys.open_weather}" - ) - return filled_url - - @auxiliary.with_typing - @commands.command( - name="we", - aliases=["weather", "wea"], - brief="Searches for the weather", - description=( - "Returns the weather for a given area (this API sucks; I'm sorry in" - " advance)" - ), - usage="[city/town] [state-code] [country-code]", + @app_commands.command( + name="weather", + description="Gets weather data for a specific location", ) - async def weather( - self: Self, - ctx: commands.Context, - city_name: str, - state_code: str = None, - country_code: str = None, + async def get_weather( + self: Self, interaction: discord.Interaction, city: str, country: str = None ) -> None: - """This is the main logic for the weather command. This prepares the API data - and sends a message to discord + """This command gets weather from open-meteo and displays it in a fancy embed Args: - ctx (commands.Context): The context generated by running this command - city_name (str): For the API, the name of the city to get weather for - state_code (str, optional): For the API, if applicable, the state code to search for. - Defaults to None. - country_code (str, optional): For the API, if needed you can add a country code to - search for. Defaults to None. + interaction (discord.Interaction): The interaction that called this command + city (str): The city to get weather for + country (str, optional): If desired, the country to search in. + Defaults to all countries. """ - response = await self.bot.http_functions.http_call( - "get", self.get_url([city_name, state_code, country_code]) + await interaction.response.defer() + geo_api_url = "https://geocoding-api.open-meteo.com/v1/search?name={}&count=10" + weather_api_url = ( + "https://api.open-meteo.com/v1/forecast?" + "latitude={}&longitude={}¤t={}&daily={}&timezone=auto" + ) + + current_params = [ + "temperature_2m", + "relative_humidity_2m", + "wind_speed_10m", + "apparent_temperature", + "weather_code", + "wind_direction_10m", + "is_day", + ] + daily_params = ["temperature_2m_max", "temperature_2m_min"] + current_params_str = ",".join(current_params) + daily_params_str = ",".join(daily_params) + + fill_str = f"{city}" + (f"&country={country}" if country else "") + filled_geo_url = geo_api_url.format(fill_str) + geo_response = await self.bot.http_functions.http_call("get", filled_geo_url) + + if not geo_response or not geo_response.get("results"): + embed = auxiliary.prepare_deny_embed( + f"I was not able to find any locations matching {city}, {country}" + ) + await interaction.followup.send(embed=embed) + return + + if country: + valid_locations = [ + entry + for entry in geo_response.results + if entry.country.lower() == country.lower() + ] + else: + valid_locations = geo_response.results + + if not valid_locations: + embed = auxiliary.prepare_deny_embed( + f"I was not able to filter any locations matching {city}, {country}" + ) + await interaction.followup.send(embed=embed) + return + + city_name = valid_locations[0].name + city_country = valid_locations[0].country + latitude = valid_locations[0].latitude + longitude = valid_locations[0].longitude + filled_weather_url = weather_api_url.format( + latitude, longitude, current_params_str, daily_params_str + ) + weather_response = await self.bot.http_functions.http_call( + "get", filled_weather_url ) - embed = self.generate_embed(munch.munchify(response)) + embed = self.generate_embed(city_name, city_country, weather_response) + if not embed: - await auxiliary.send_deny_embed( - message="I could not find the weather from your search", - channel=ctx.channel, + embed = auxiliary.prepare_deny_embed( + f"I was not able to get any weather for {city_name}, {city_country}" ) + await interaction.followup.send(embed=embed) return - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) - def generate_embed(self: Self, response: munch.Munch) -> discord.Embed | None: - """Creates an embed filled with weather data: - Current Temp - High temp - Low temp - Humidity - Condition + def generate_embed( + self: Self, city_name: str, country_name: str, weather_response: munch.Munch + ) -> discord.Embed | None: + """This generates an embed from passed weather and location data Args: - response (munch.Munch): The response from the API containing the weather data + city_name (str): The name of the city the weather is for + country_name (str): The name of the country the weather is for + weather_response (munch.Munch): The raw response from the open meteo API Returns: - discord.Embed | None: Either the formatted embed, or nothing if the API failed + discord.Embed | None: The embed that contains the weather data """ try: - embed = discord.Embed( - title=f"Weather for {response.name} ({response.sys.country})" + embed = discord.Embed(title=f"Weather for {city_name}, {country_name}") + daytime = bool(weather_response.current.is_day) + + wmo_code = str(weather_response.current.weather_code) + + if wmo_code in self.wmo_map: + description = ( + self.wmo_map[wmo_code].day.description + if daytime + else self.wmo_map[wmo_code].night.description + ) + image = ( + self.wmo_map[wmo_code].day.image + if daytime + else self.wmo_map[wmo_code].night.image + ) + embed.add_field( + name="Description", + value=description, + ) + embed.set_thumbnail(url=image) + else: + embed.add_field( + name="Description", + value=f"Unknown WMO code {wmo_code}", + ) + + embed.color = discord.Color.blurple() + + embed.add_field( + name="Current temperature", + value=format_temperature(weather_response.current.temperature_2m), ) - descriptions = ", ".join( - weather.description for weather in response.weather + embed.add_field( + name="Feels like", + value=format_temperature(weather_response.current.apparent_temperature), ) - embed.add_field(name="Description", value=descriptions, inline=False) embed.add_field( - name="Temp (F)", - value=( - f"{int(response.main.temp)} (feels like" - f" {int(response.main.feels_like)})" - ), - inline=False, + name="Low temperature", + value=format_temperature(weather_response.daily.temperature_2m_min[0]), ) - embed.add_field(name="Low (F)", value=int(response.main.temp_min)) + embed.add_field( - name="High (F)", - value=int(response.main.temp_max), + name="High temperature", + value=format_temperature(weather_response.daily.temperature_2m_max[0]), ) - embed.add_field(name="Humidity", value=f"{int(response.main.humidity)} %") - embed.set_thumbnail( - url=( - "https://www.iconarchive.com/download/i76758" - "/pixelkit/flat-jewels/Weather.512.png" - ) + + embed.add_field( + name="Humidity", + value=f"{weather_response.current.relative_humidity_2m}%", ) - embed.color = discord.Color.blurple() + + wind_direction = format_wind_direction( + weather_response.current.wind_direction_10m + ) + embed.add_field( + name="Wind", + value=f"{format_speed(weather_response.current.wind_speed_10m)} {wind_direction}", + ) + except AttributeError: embed = None return embed + + +def format_temperature(temp_c: int) -> str: + """This formats a temp given in celsius with the format: + X°C (Y°F) + + Args: + temp_c (int): The temp in celsius to process + + Returns: + str: The formatted temp string + """ + return f"{temp_c:.1f}°C ({convert_c_to_f(temp_c):.1f}°F)" + + +def format_speed(speed_kmh: int) -> str: + """This formats a speed given in kilometers per hour with the format: + X km/h (Y mph) + + Args: + speed_kmh (int): The speed in kilometers per hour to process + + Returns: + str: The formatted speed string + """ + return f"{speed_kmh:.1f} km/h ({speed_kmh * 0.621371:.1f} mph)" + + +def convert_c_to_f(temp_c: int) -> float: + """This converts celsius to fahrenheit + + Args: + temp_c (int): The temp in celsius to convert + + Returns: + float: The temperature in fahrenheit + """ + return (temp_c * 9 / 5) + 32 + + +def format_wind_direction(direction: int) -> str: + """Converts a wind direction in degrees to a cardinal direction. + + Args: + direction (int): The wind direction in degrees + + Returns: + str: The cardinal direction + """ + directions = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + ] + + return directions[round(direction / 22.5) % 16] diff --git a/resources/wmo-map.json b/resources/wmo-map.json new file mode 100644 index 000000000..b34a3c375 --- /dev/null +++ b/resources/wmo-map.json @@ -0,0 +1,282 @@ +{ + "0":{ + "day":{ + "description":"Sunny", + "image":"http://openweathermap.org/img/wn/01d@2x.png" + }, + "night":{ + "description":"Clear", + "image":"http://openweathermap.org/img/wn/01n@2x.png" + } + }, + "1":{ + "day":{ + "description":"Mainly Sunny", + "image":"http://openweathermap.org/img/wn/01d@2x.png" + }, + "night":{ + "description":"Mainly Clear", + "image":"http://openweathermap.org/img/wn/01n@2x.png" + } + }, + "2":{ + "day":{ + "description":"Partly Cloudy", + "image":"http://openweathermap.org/img/wn/02d@2x.png" + }, + "night":{ + "description":"Partly Cloudy", + "image":"http://openweathermap.org/img/wn/02n@2x.png" + } + }, + "3":{ + "day":{ + "description":"Cloudy", + "image":"http://openweathermap.org/img/wn/03d@2x.png" + }, + "night":{ + "description":"Cloudy", + "image":"http://openweathermap.org/img/wn/03n@2x.png" + } + }, + "45":{ + "day":{ + "description":"Foggy", + "image":"http://openweathermap.org/img/wn/50d@2x.png" + }, + "night":{ + "description":"Foggy", + "image":"http://openweathermap.org/img/wn/50n@2x.png" + } + }, + "48":{ + "day":{ + "description":"Rime Fog", + "image":"http://openweathermap.org/img/wn/50d@2x.png" + }, + "night":{ + "description":"Rime Fog", + "image":"http://openweathermap.org/img/wn/50n@2x.png" + } + }, + "51":{ + "day":{ + "description":"Light Drizzle", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Light Drizzle", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "53":{ + "day":{ + "description":"Drizzle", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Drizzle", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "55":{ + "day":{ + "description":"Heavy Drizzle", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Heavy Drizzle", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "56":{ + "day":{ + "description":"Light Freezing Drizzle", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Light Freezing Drizzle", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "57":{ + "day":{ + "description":"Freezing Drizzle", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Freezing Drizzle", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "61":{ + "day":{ + "description":"Light Rain", + "image":"http://openweathermap.org/img/wn/10d@2x.png" + }, + "night":{ + "description":"Light Rain", + "image":"http://openweathermap.org/img/wn/10n@2x.png" + } + }, + "63":{ + "day":{ + "description":"Rain", + "image":"http://openweathermap.org/img/wn/10d@2x.png" + }, + "night":{ + "description":"Rain", + "image":"http://openweathermap.org/img/wn/10n@2x.png" + } + }, + "65":{ + "day":{ + "description":"Heavy Rain", + "image":"http://openweathermap.org/img/wn/10d@2x.png" + }, + "night":{ + "description":"Heavy Rain", + "image":"http://openweathermap.org/img/wn/10n@2x.png" + } + }, + "66":{ + "day":{ + "description":"Light Freezing Rain", + "image":"http://openweathermap.org/img/wn/10d@2x.png" + }, + "night":{ + "description":"Light Freezing Rain", + "image":"http://openweathermap.org/img/wn/10n@2x.png" + } + }, + "67":{ + "day":{ + "description":"Freezing Rain", + "image":"http://openweathermap.org/img/wn/10d@2x.png" + }, + "night":{ + "description":"Freezing Rain", + "image":"http://openweathermap.org/img/wn/10n@2x.png" + } + }, + "71":{ + "day":{ + "description":"Light Snow", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Light Snow", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "73":{ + "day":{ + "description":"Snow", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Snow", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "75":{ + "day":{ + "description":"Heavy Snow", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Heavy Snow", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "77":{ + "day":{ + "description":"Snow Grains", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Snow Grains", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "80":{ + "day":{ + "description":"Light Showers", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Light Showers", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "81":{ + "day":{ + "description":"Showers", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Showers", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "82":{ + "day":{ + "description":"Heavy Showers", + "image":"http://openweathermap.org/img/wn/09d@2x.png" + }, + "night":{ + "description":"Heavy Showers", + "image":"http://openweathermap.org/img/wn/09n@2x.png" + } + }, + "85":{ + "day":{ + "description":"Light Snow Showers", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Light Snow Showers", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "86":{ + "day":{ + "description":"Snow Showers", + "image":"http://openweathermap.org/img/wn/13d@2x.png" + }, + "night":{ + "description":"Snow Showers", + "image":"http://openweathermap.org/img/wn/13n@2x.png" + } + }, + "95":{ + "day":{ + "description":"Thunderstorm", + "image":"http://openweathermap.org/img/wn/11d@2x.png" + }, + "night":{ + "description":"Thunderstorm", + "image":"http://openweathermap.org/img/wn/11n@2x.png" + } + }, + "96":{ + "day":{ + "description":"Light Thunderstorms With Hail", + "image":"http://openweathermap.org/img/wn/11d@2x.png" + }, + "night":{ + "description":"Light Thunderstorms With Hail", + "image":"http://openweathermap.org/img/wn/11n@2x.png" + } + }, + "99":{ + "day":{ + "description":"Thunderstorm With Hail", + "image":"http://openweathermap.org/img/wn/11d@2x.png" + }, + "night":{ + "description":"Thunderstorm With Hail", + "image":"http://openweathermap.org/img/wn/11n@2x.png" + } + } +}