Skip to content

Commit

Permalink
feat(nodules): add weather module
Browse files Browse the repository at this point in the history
  • Loading branch information
HitaloM committed Dec 12, 2024
1 parent 8097fd0 commit 647a0b3
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 23 deletions.
1 change: 1 addition & 0 deletions docs/source/modules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions docs/source/modules/weather.md
Original file line number Diff line number Diff line change
@@ -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.
89 changes: 66 additions & 23 deletions locales/bot.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand Down Expand Up @@ -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 <code>/{action} "
"&lt;commandname&gt;</code>."
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"
"- <code>{command}</code>\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 ""

Expand Down Expand Up @@ -1175,6 +1175,49 @@ msgstr ""
msgid "<b>User link</b>: <a href='tg://user?id={id}'>link</a>\n"
msgstr ""

#: src/korone/modules/weather/handlers/get.py:23
msgid ""
"No location provided. You should provide a location. Example: "
"<code>/weather Rio de Janeiro</code>"
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 "
Expand Down
1 change: 1 addition & 0 deletions news/+weather.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New `Weather` module to get weather information for a location, read more in :doc:`Weather module <modules/weather>`.
2 changes: 2 additions & 0 deletions src/korone/modules/weather/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>
10 changes: 10 additions & 0 deletions src/korone/modules/weather/callback_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>

from hairydogm.filters.callback_data import CallbackData


class WeatherCallbackData(CallbackData, prefix="weather"):
latitude: float | None = None
longitude: float | None = None
page: int | None = None
2 changes: 2 additions & 0 deletions src/korone/modules/weather/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>
128 changes: 128 additions & 0 deletions src/korone/modules/weather/handlers/get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>

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: <code>/weather Rio de Janeiro</code>"
)
)
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"<b>{weather_result.city}, "
f"{weather_result.admin_district}, "
f"{weather_result.country}</b>:\n\n"
f"🌡️ <b>{_('Temperature')}</b>: {weather_result.temperature}°C\n"
f"🌡️ <b>{_('Temperature feels like:')}</b>: {weather_result.temperature_feels_like}°C\n"
f"💧 <b>{_('Air humidity:')}</b>: {weather_result.relative_humidity}%\n"
f"💨 <b>{_('Wind Speed')}</b>: {weather_result.wind_speed} km/h\n\n"
f"- 🌤️ <i>{weather_result.wx_phrase_long}</i>"
)

await callback_query.edit_message_text(text=text)
2 changes: 2 additions & 0 deletions src/korone/modules/weather/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>
52 changes: 52 additions & 0 deletions src/korone/modules/weather/utils/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>

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)
24 changes: 24 additions & 0 deletions src/korone/modules/weather/utils/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Hitalo M. <https://github.com/HitaloM>

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")

0 comments on commit 647a0b3

Please sign in to comment.