diff --git a/README.md b/README.md index 97f9681..88dc45a 100644 --- a/README.md +++ b/README.md @@ -90,4 +90,4 @@ Drop the file under `bot/extensions/` and it will be auto-discovered and loaded - The file must define an `extension` attribute, or the bot will raise a `RuntimeError` on startup. - The extension name passed to `Extension()` should match the module's purpose. -- Each extension should stay focused on a single feature. \ No newline at end of file +- Each extension should stay focused on a single feature. diff --git a/bot/extensions/weather/__init__.py b/bot/extensions/weather/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/extensions/weather/openweather_service.py b/bot/extensions/weather/openweather_service.py new file mode 100644 index 0000000..8625336 --- /dev/null +++ b/bot/extensions/weather/openweather_service.py @@ -0,0 +1,88 @@ +import logging +import requests + +from collections.abc import Mapping +from enum import Enum +from typing import cast +from typing import NotRequired, TypedDict + +logger = logging.getLogger(__name__) + +OPENWEATHER_URL = "https://api.openweathermap.org/data/2.5/weather" + + +class WeatherCondition(TypedDict): + """OpenWeather condition fields used when formatting a forecast.""" + + description: str + icon: NotRequired[str] # TODO: figure out how to render icons + + +class MainPayload(TypedDict): + """OpenWeather temperature and atmosphere fields used by the formatter.""" + + temp: float + feels_like: float + temp_min: float + temp_max: float + pressure: int + humidity: int + + +class WeatherPayload(TypedDict): + """TypedDict for the weather fields used when formatting a forecast.""" + + weather: list[WeatherCondition] + main: MainPayload + visibility: int | None + timestamp: int + + +class WeatherError(Enum): + """List of possible errors when fetching weather data.""" + + NOT_FOUND = "not_found" + UNAVAILABLE = "unavailable" + + +def fetch_weather( + api_key: str, + city: str, +) -> WeatherPayload | WeatherError: + """Retrieve weather information for the specified city.""" + + try: + response = requests.get( + OPENWEATHER_URL, + params={"appid": api_key, "q": city}, + timeout=10, + ) + + if response.status_code == 404: + return WeatherError.NOT_FOUND + + response.raise_for_status() + return _normalize_weather_payload(response.json()) + except requests.HTTPError as error: + logger.warning("Weather API HTTP error for %r: %s", city, error) + except (requests.ConnectionError, requests.Timeout) as error: + logger.warning("Weather API connection error for %r: %s", city, error) + except (KeyError, TypeError, ValueError) as error: + logger.warning("Weather API payload error for %r: %s", city, error) + + return WeatherError.UNAVAILABLE + + +def _normalize_weather_payload(payload: object) -> WeatherPayload: + raw_payload = cast(Mapping[str, object], payload) + timezone = int(cast(int | str, raw_payload.get("timezone", 0))) + raw_visibility = raw_payload.get("visibility") + + return { + "weather": cast(list[WeatherCondition], raw_payload["weather"]), + "main": cast(MainPayload, raw_payload["main"]), + "visibility": ( + int(cast(int | str, raw_visibility)) if raw_visibility is not None else None + ), + "timestamp": int(cast(int | str, raw_payload["dt"])) + timezone, + } diff --git a/bot/extensions/weather/weather_extension.py b/bot/extensions/weather/weather_extension.py new file mode 100644 index 0000000..9502c53 --- /dev/null +++ b/bot/extensions/weather/weather_extension.py @@ -0,0 +1,49 @@ +from matrix import Context, Extension +from matrix.errors import CheckError +from .openweather_service import WeatherError, fetch_weather +from .weather_helper import format_weather + +extension = Extension("weather") + + +def _normalize_city_name(city_parts: tuple[str, ...]) -> str: + return " ".join(city_parts).title().strip() + + +def _get_api_key() -> str | None: + return extension.bot.config.get("api_key", section="extensions.weather") + + +@extension.check +async def has_api_key(_ctx: Context) -> bool: + return _get_api_key() is not None + + +@extension.error(CheckError) +async def missing_api_key(ctx: Context, error: CheckError) -> None: + if isinstance(error, CheckError): + await ctx.reply( + "Weather extension is not configured with an API key. " + "Please set the api_key in the configuration to use this command." + ) + + +@extension.command( + "weather", description="Show current weather information for a city." +) +async def weather(ctx: Context, *city: str) -> None: + city_name = _normalize_city_name(city) + + api_key: str | None = _get_api_key() + if not api_key: + await ctx.reply("Weather is not configured.") + return + + result = fetch_weather(api_key, city_name) + match result: + case WeatherError.NOT_FOUND: + await ctx.reply(f"Could not find weather data for {city_name}.") + case WeatherError.UNAVAILABLE: + await ctx.reply("Weather service is temporarily unavailable.") + case _: + await ctx.reply(format_weather(city_name, result)) diff --git a/bot/extensions/weather/weather_helper.py b/bot/extensions/weather/weather_helper.py new file mode 100644 index 0000000..54aca5e --- /dev/null +++ b/bot/extensions/weather/weather_helper.py @@ -0,0 +1,68 @@ +from datetime import UTC, datetime +from .openweather_service import WeatherPayload + + +def format_weather(city: str, data: WeatherPayload) -> str: + current_time = _city_time(data["timestamp"]).strftime("%Y-%m-%d %H:%M") + weather = data["weather"][0] + main = data["main"] + + visibility_m = data["visibility"] + visibility_ft = ( + f"{round(visibility_m * 3.280839895):,}ft" + if visibility_m is not None + else "n/a" + ) + + description = weather["description"].title() + + temperature = _format_temperature(main["temp"]) + feels_like = _format_temperature(main["feels_like"]) + temp_min = _format_temperature(main["temp_min"]) + temp_max = _format_temperature(main["temp_max"]) + + humidity = main["humidity"] + pressure = main["pressure"] + visibility = _format_visibility(visibility_m, visibility_ft) + + return ( + f"### Weather for {city}\n" + f"
"
+        f"{'Local time:':<14} {current_time}\n"
+        f"{'Description:':<14} {description}\n"
+        f"{'Temperature:':<14} {temperature}\n"
+        f"{'- Min:':<14} {temp_min}\n"
+        f"{'- Max:':<14} {temp_max}\n"
+        f"{'Feels like:':<14} {feels_like}\n"
+        f"{'Humidity:':<14} {humidity}%\n"
+        f"{'Pressure:':<14} {pressure:,} hPa\n"
+        f"{'Visibility:':<14} {visibility}\n"
+        f"
" + ) + + +def _city_time(timestamp: int) -> datetime: + """Get the local time for the city from a normalized timestamp.""" + + return datetime.fromtimestamp(timestamp, UTC) + + +def _format_visibility(visibility_m: int | None, visibility_ft: str) -> str: + if visibility_m is None: + return "n/a" + + return f"{visibility_m:,}m | {visibility_ft}" + + +def _format_temperature(kelvin: float) -> str: + fahrenheit = _kelvin_to_fahrenheit(kelvin) + celsius = _kelvin_to_celsius(kelvin) + return f"{fahrenheit:.2f}°F | {celsius:.2f}°C" + + +def _kelvin_to_celsius(kelvin: float) -> float: + return kelvin - 273.15 + + +def _kelvin_to_fahrenheit(kelvin: float) -> float: + return kelvin * 1.8 - 459.67 diff --git a/config/development.yaml b/config/development.yaml index d0ae638..350b58e 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -14,3 +14,5 @@ bot: extensions: welcome: room: "${ADA_MAIN_ROOM|}" + weather: + api_key: "${ADA_OPENWEATHER_API_KEY|}" diff --git a/config/production.yaml b/config/production.yaml index 4667fe2..78c0a63 100644 --- a/config/production.yaml +++ b/config/production.yaml @@ -13,3 +13,5 @@ bot: extensions: welcome: room: "!OaOPoyVKqbmPEcMBbt:matrix.org" + weather: + api_key: "${ADA_OPENWEATHER_API_KEY|}" diff --git a/tests/extensions/test_weather_extension.py b/tests/extensions/test_weather_extension.py new file mode 100644 index 0000000..cc9a481 --- /dev/null +++ b/tests/extensions/test_weather_extension.py @@ -0,0 +1,100 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from bot.extensions.weather.openweather_service import WeatherError, fetch_weather +from bot.extensions.weather.weather_helper import ( + _city_time, + _format_temperature, + _format_visibility, + format_weather, + _kelvin_to_celsius, + _kelvin_to_fahrenheit, +) + +from bot.extensions.weather.weather_extension import _normalize_city_name + + +def test_kelvin_to_celsius() -> None: + assert round(_kelvin_to_celsius(273.15), 2) == 0 + + +def test_kelvin_to_fahrenheit() -> None: + assert round(_kelvin_to_fahrenheit(273.15), 2) == 32 + + +def test_format_temperature() -> None: + assert _format_temperature(273.15) == "32.00°F | 0.00°C" + + +def test_format_visibility_handles_missing_value() -> None: + assert _format_visibility(None, "n/a") == "n/a" + + +def test_normalize_city_name_preserves_multiword_city() -> None: + assert _normalize_city_name(("los", "angeles")) == "Los Angeles" + + +def test_city_time_uses_timestamp() -> None: + assert _city_time(0) == datetime.fromtimestamp(0, UTC) + + +def test_fetch_weather_normalizes_openweather_payload() -> None: + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = { + "dt": 1000, + "timezone": 3600, + "visibility": 10000, + "weather": [{"description": "clear sky", "icon": "01d"}], + "main": { + "temp": 293.15, + "feels_like": 294.15, + "temp_min": 292.15, + "temp_max": 295.15, + "humidity": 52, + "pressure": 1014, + }, + } + + with patch( + "bot.extensions.weather.openweather_service.requests.get", + return_value=fake_response, + ): + result = fetch_weather("api-key", "Paris") + + assert not isinstance(result, WeatherError) + assert result["timestamp"] == 4600 + assert result["visibility"] == 10000 + + +def test_format_weather() -> None: + message = format_weather( + "Paris", + { + "timestamp": 0, + "visibility": 10000, + "weather": [{"description": "clear sky", "icon": "01d"}], + "main": { + "temp": 293.15, + "feels_like": 294.15, + "temp_min": 292.15, + "temp_max": 295.15, + "humidity": 52, + "pressure": 1014, + }, + }, + ) + + lines = message.splitlines() + + assert lines[0] == "### Weather for Paris" + assert lines[1] == "
Local time:    1970-01-01 00:00"
+    assert lines[2] == "Description:   Clear Sky"
+    assert lines[3] == "Temperature:   68.00°F | 20.00°C"
+    assert lines[4] == "- Min:         66.20°F | 19.00°C"
+    assert lines[5] == "- Max:         71.60°F | 22.00°C"
+    assert lines[6] == "Feels like:    69.80°F | 21.00°C"
+    assert lines[7] == "Humidity:      52%"
+    assert lines[8] == "Pressure:      1,014 hPa"
+    assert lines[9] == "Visibility:    10,000m | 32,808ft"
+    assert lines[10] == "
"