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] == ""