Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion config.default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ api:
giphy:
google:
news:
open_weather:
openai:
spotify_client:
spotify_key:
Expand Down
299 changes: 209 additions & 90 deletions modules/utility/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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={}&current={}&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]
Loading
Loading