From 647a0b3d004bfb99b27d486f2fa97d616c35e0e5 Mon Sep 17 00:00:00 2001 From: "Hitalo M." Date: Thu, 12 Dec 2024 18:17:21 -0300 Subject: [PATCH] feat(nodules): add weather module --- docs/source/modules/index.md | 1 + docs/source/modules/weather.md | 7 + locales/bot.pot | 89 ++++++++---- news/+weather.feature.rst | 1 + src/korone/modules/weather/__init__.py | 2 + src/korone/modules/weather/callback_data.py | 10 ++ .../modules/weather/handlers/__init__.py | 2 + src/korone/modules/weather/handlers/get.py | 128 ++++++++++++++++++ src/korone/modules/weather/utils/__init__.py | 2 + src/korone/modules/weather/utils/api.py | 52 +++++++ src/korone/modules/weather/utils/types.py | 24 ++++ 11 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 docs/source/modules/weather.md create mode 100644 news/+weather.feature.rst create mode 100644 src/korone/modules/weather/__init__.py create mode 100644 src/korone/modules/weather/callback_data.py create mode 100644 src/korone/modules/weather/handlers/__init__.py create mode 100644 src/korone/modules/weather/handlers/get.py create mode 100644 src/korone/modules/weather/utils/__init__.py create mode 100644 src/korone/modules/weather/utils/api.py create mode 100644 src/korone/modules/weather/utils/types.py diff --git a/docs/source/modules/index.md b/docs/source/modules/index.md index e330410918..51b1b16c4f 100644 --- a/docs/source/modules/index.md +++ b/docs/source/modules/index.md @@ -53,4 +53,5 @@ Below is a list of all available modules in _PyKorone_: - [Stickers](./stickers): Steal stickers from sticker packs. - [Translator](./translator): Translate text to different languages using DeepL. - [Users](./users): Fetches information about Telegram and _PyKorone_ users. +- [Weather](./weather): Get weather information for a specific location. - [Web Tools](./web): Get information about IP addresses and domains. diff --git a/docs/source/modules/weather.md b/docs/source/modules/weather.md new file mode 100644 index 0000000000..75a74bc666 --- /dev/null +++ b/docs/source/modules/weather.md @@ -0,0 +1,7 @@ +# Weather + +The Weather module enables you to retrieve weather information for a particular location. It offers commands to obtain current weather conditions. + +## Commands + +- `/weather (location)`: Retrieves the current weather information for the specified location. diff --git a/locales/bot.pot b/locales/bot.pot index 1e59972c15..7ed87650d8 100644 --- a/locales/bot.pot +++ b/locales/bot.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-12-11 12:38-0300\n" +"POT-Creation-Date: 2024-12-12 18:14-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -87,44 +87,44 @@ msgstr "" msgid "The following commands are disabled in this chat:\n" msgstr "" -#: src/korone/modules/disabling/handlers/toggle.py:28 -msgid "enable" -msgstr "" - -#: src/korone/modules/disabling/handlers/toggle.py:28 -msgid "disable" -msgstr "" - -#: src/korone/modules/disabling/handlers/toggle.py:29 -msgid "enabled" -msgstr "" - -#: src/korone/modules/disabling/handlers/toggle.py:29 -msgid "disabled" -msgstr "" - -#: src/korone/modules/disabling/handlers/toggle.py:33 +#: src/korone/modules/disabling/handlers/toggle.py:30 msgid "" "You need to specify a command to {action}. Use /{action} " "<commandname>." msgstr "" -#: src/korone/modules/disabling/handlers/toggle.py:43 -msgid "You can only {action} one command at a time." +#: src/korone/modules/disabling/handlers/toggle.py:33 +#: src/korone/modules/disabling/handlers/toggle.py:41 +msgid "enable" msgstr "" -#: src/korone/modules/disabling/handlers/toggle.py:50 +#: src/korone/modules/disabling/handlers/toggle.py:33 +#: src/korone/modules/disabling/handlers/toggle.py:41 +msgid "disable" +msgstr "" + +#: src/korone/modules/disabling/handlers/toggle.py:39 msgid "" "Unknown command to {action}:\n" "- {command}\n" "Check the /disableable!" msgstr "" -#: src/korone/modules/disabling/handlers/toggle.py:58 +#: src/korone/modules/disabling/handlers/toggle.py:50 msgid "This command is already {action}." msgstr "" -#: src/korone/modules/disabling/handlers/toggle.py:62 +#: src/korone/modules/disabling/handlers/toggle.py:51 +#: src/korone/modules/disabling/handlers/toggle.py:58 +msgid "enabled" +msgstr "" + +#: src/korone/modules/disabling/handlers/toggle.py:51 +#: src/korone/modules/disabling/handlers/toggle.py:58 +msgid "disabled" +msgstr "" + +#: src/korone/modules/disabling/handlers/toggle.py:58 msgid "Command {action}." msgstr "" @@ -1175,6 +1175,49 @@ msgstr "" msgid "User link: link\n" msgstr "" +#: src/korone/modules/weather/handlers/get.py:23 +msgid "" +"No location provided. You should provide a location. Example: " +"/weather Rio de Janeiro" +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:36 +#: src/korone/modules/weather/handlers/get.py:99 +msgid "Failed to fetch weather data." +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:47 +msgid "No locations found for the provided query." +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:60 +msgid "Please select a location:" +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:74 +msgid "Session expired. Please try again." +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:106 +msgid "Incomplete weather data received." +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:121 +msgid "Temperature" +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:122 +msgid "Temperature feels like:" +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:123 +msgid "Air humidity:" +msgstr "" + +#: src/korone/modules/weather/handlers/get.py:124 +msgid "Wind Speed" +msgstr "" + #: src/korone/modules/web/handlers/ip.py:20 msgid "" "You should provide an IP address or domain name to get " diff --git a/news/+weather.feature.rst b/news/+weather.feature.rst new file mode 100644 index 0000000000..1687caccf1 --- /dev/null +++ b/news/+weather.feature.rst @@ -0,0 +1 @@ +New `Weather` module to get weather information for a location, read more in :doc:`Weather module `. diff --git a/src/korone/modules/weather/__init__.py b/src/korone/modules/weather/__init__.py new file mode 100644 index 0000000000..faa1bc4143 --- /dev/null +++ b/src/korone/modules/weather/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. diff --git a/src/korone/modules/weather/callback_data.py b/src/korone/modules/weather/callback_data.py new file mode 100644 index 0000000000..99197daac1 --- /dev/null +++ b/src/korone/modules/weather/callback_data.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. + +from hairydogm.filters.callback_data import CallbackData + + +class WeatherCallbackData(CallbackData, prefix="weather"): + latitude: float | None = None + longitude: float | None = None + page: int | None = None diff --git a/src/korone/modules/weather/handlers/__init__.py b/src/korone/modules/weather/handlers/__init__.py new file mode 100644 index 0000000000..faa1bc4143 --- /dev/null +++ b/src/korone/modules/weather/handlers/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. diff --git a/src/korone/modules/weather/handlers/get.py b/src/korone/modules/weather/handlers/get.py new file mode 100644 index 0000000000..8b4f1cf212 --- /dev/null +++ b/src/korone/modules/weather/handlers/get.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. + +from hydrogram import Client +from hydrogram.types import CallbackQuery, Message + +from korone.decorators import router +from korone.filters.command import Command, CommandObject +from korone.modules.weather.callback_data import WeatherCallbackData +from korone.modules.weather.utils.api import get_weather_observations, search_location +from korone.modules.weather.utils.types import WeatherResult, WeatherSearch +from korone.utils.caching import cache +from korone.utils.i18n import gettext as _ +from korone.utils.pagination import Pagination + + +@router.message(Command(commands=["weather", "clima"])) +async def get_weather(client: Client, message: Message) -> None: + command = CommandObject(message).parse() + + if not command.args or len(command.args) <= 1: + await message.reply_text( + _( + "No location provided. You should provide a location. " + "Example: /weather Rio de Janeiro" + ) + ) + return + + cache_key = f"weather_location_{command.args}" + data = await cache.get(cache_key) or await search_location(command.args) + + if data: + await cache.set(cache_key, data, expire=3600) + else: + await message.reply_text(_("Failed to fetch weather data.")) + return + + weather_search = WeatherSearch.model_validate(data.get("location", {})) + locations = list( + zip( + weather_search.address, weather_search.latitude, weather_search.longitude, strict=False + ) + ) + + if not locations: + await message.reply_text(_("No locations found for the provided query.")) + return + + pagination = Pagination( + objects=locations, + page_data=lambda page: WeatherCallbackData(page=page).pack(), + item_data=lambda item, _: WeatherCallbackData(latitude=item[1], longitude=item[2]).pack(), + item_title=lambda item, _: item[0], + ) + + keyboard_markup = pagination.create(page=1) + await cache.set(f"weather_locations_{message.chat.id}", locations, expire=300) + + await message.reply(_("Please select a location:"), reply_markup=keyboard_markup) + + +@router.callback_query(WeatherCallbackData.filter()) +async def callback_weather(client: Client, callback_query: CallbackQuery) -> None: + if not callback_query.data: + return + + data = WeatherCallbackData.unpack(callback_query.data) + chat_id = callback_query.message.chat.id + + if data.page: + locations = await cache.get(f"weather_locations_{chat_id}") + if not locations: + await callback_query.answer(_("Session expired. Please try again."), show_alert=True) + return + + pagination = Pagination( + objects=locations, + page_data=lambda page: WeatherCallbackData(page=page).pack(), + item_data=lambda item, _: WeatherCallbackData( + latitude=item[1], longitude=item[2] + ).pack(), + item_title=lambda item, _: item[0], + ) + keyboard_markup = pagination.create(page=data.page) + + await callback_query.edit_message_reply_markup(reply_markup=keyboard_markup) + return + + if data.latitude and data.longitude: + cache_key = f"weather_data_{data.latitude}_{data.longitude}" + weather_data = await cache.get(cache_key) or await get_weather_observations( + data.latitude, data.longitude + ) + + if weather_data: + await cache.set(cache_key, weather_data, expire=600) + else: + await callback_query.answer(_("Failed to fetch weather data."), show_alert=True) + return + + location_data = weather_data.get("v3-location-point", {}).get("location", {}) + weather_observation = weather_data.get("v3-wx-observations-current", {}) + + if not location_data or not weather_observation: + await callback_query.answer(_("Incomplete weather data received."), show_alert=True) + return + + weather_result = WeatherResult.model_validate({ + **weather_observation, + "id": location_data.get("locId"), + "city": location_data.get("city"), + "adminDistrict": location_data.get("adminDistrict"), + "country": location_data.get("country"), + }) + + text = ( + f"{weather_result.city}, " + f"{weather_result.admin_district}, " + f"{weather_result.country}:\n\n" + f"🌡️ {_('Temperature')}: {weather_result.temperature}°C\n" + f"🌡️ {_('Temperature feels like:')}: {weather_result.temperature_feels_like}°C\n" + f"💧 {_('Air humidity:')}: {weather_result.relative_humidity}%\n" + f"💨 {_('Wind Speed')}: {weather_result.wind_speed} km/h\n\n" + f"- 🌤️ {weather_result.wx_phrase_long}" + ) + + await callback_query.edit_message_text(text=text) diff --git a/src/korone/modules/weather/utils/__init__.py b/src/korone/modules/weather/utils/__init__.py new file mode 100644 index 0000000000..faa1bc4143 --- /dev/null +++ b/src/korone/modules/weather/utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. diff --git a/src/korone/modules/weather/utils/api.py b/src/korone/modules/weather/utils/api.py new file mode 100644 index 0000000000..802181a4e1 --- /dev/null +++ b/src/korone/modules/weather/utils/api.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. + +from datetime import timedelta +from typing import Any + +import httpx + +from korone.utils.caching import cache +from korone.utils.logging import logger + +WEATHER_API_KEY = "8de2d8b3a93542c9a2d8b3a935a2c909" +LOCATION_SEARCH_URL = "https://api.weather.com/v3/location/search" +WEATHER_OBSERVATIONS_URL = ( + "https://api.weather.com/v3/aggcommon/v3-wx-observations-current;v3-location-point" +) +LANGUAGE = "en-US" +FORMAT = "json" +UNITS = "m" + + +async def fetch_json(url: str, params: dict) -> dict: + async with httpx.AsyncClient(http2=True, timeout=httpx.Timeout(10.0)) as client: + try: + response = await client.get(url, params=params, timeout=10.0) + response.raise_for_status() + return response.json() + except httpx.HTTPError as err: + await logger.aexception("[Weather] HTTP error occurred: %s", err) + return {} + + +@cache(ttl=timedelta(days=1)) +async def search_location(query: str) -> dict[str, Any]: + params = { + "apiKey": WEATHER_API_KEY, + "query": query, + "language": LANGUAGE, + "format": FORMAT, + } + return await fetch_json(LOCATION_SEARCH_URL, params) + + +async def get_weather_observations(latitude: float, longitude: float) -> dict[str, Any]: + params = { + "apiKey": WEATHER_API_KEY, + "geocode": f"{latitude},{longitude}", + "language": LANGUAGE, + "units": UNITS, + "format": FORMAT, + } + return await fetch_json(WEATHER_OBSERVATIONS_URL, params) diff --git a/src/korone/modules/weather/utils/types.py b/src/korone/modules/weather/utils/types.py new file mode 100644 index 0000000000..4c6e3061aa --- /dev/null +++ b/src/korone/modules/weather/utils/types.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Hitalo M. + +from pydantic import BaseModel, ConfigDict, Field + + +class WeatherSearch(BaseModel): + latitude: list[float] + longitude: list[float] + address: list[str] + + +class WeatherResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + weather_id: str = Field(alias="id") + temperature: float = Field(alias="temperature") + temperature_feels_like: float = Field(alias="temperatureFeelsLike") + relative_humidity: float = Field(alias="relativeHumidity") + wind_speed: float = Field(alias="windSpeed") + wx_phrase_long: str = Field(alias="wxPhraseLong") + city: str = Field(alias="city") + admin_district: str = Field(alias="adminDistrict") + country: str = Field(alias="country")