Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Each extension should stay focused on a single feature.
Empty file.
88 changes: 88 additions & 0 deletions bot/extensions/weather/openweather_service.py
Original file line number Diff line number Diff line change
@@ -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,
}
49 changes: 49 additions & 0 deletions bot/extensions/weather/weather_extension.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
chrisdedman marked this conversation as resolved.
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))
68 changes: 68 additions & 0 deletions bot/extensions/weather/weather_helper.py
Original file line number Diff line number Diff line change
@@ -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"<pre>"
Comment thread
PenguinBoi12 marked this conversation as resolved.
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"</pre>"
)


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
2 changes: 2 additions & 0 deletions config/development.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ bot:
extensions:
welcome:
room: "${ADA_MAIN_ROOM|}"
weather:
api_key: "${ADA_OPENWEATHER_API_KEY|}"
2 changes: 2 additions & 0 deletions config/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ bot:
extensions:
welcome:
room: "!OaOPoyVKqbmPEcMBbt:matrix.org"
weather:
api_key: "${ADA_OPENWEATHER_API_KEY|}"
100 changes: 100 additions & 0 deletions tests/extensions/test_weather_extension.py
Original file line number Diff line number Diff line change
@@ -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] == "<pre>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] == "</pre>"
Loading