various plugin refactors and fixes

This commit is contained in:
2026-05-09 04:51:50 -05:00
parent f822d6a450
commit 5c6234a317
25 changed files with 2044 additions and 3674 deletions
+86 -132
View File
@@ -1,11 +1,6 @@
"""
Weather plugin primary: OpenWeatherMap, fallback: OpenMeteo.
Uses OpenWeatherMap when a valid API key is present and the request succeeds.
Falls back to OpenMeteo (no key required) otherwise.
Commands:
!weather <location> e.g. !weather London or !weather "New York,US"
Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
@@ -14,56 +9,14 @@ import aiohttp
import simplematrixbotlib as botlib
from dotenv import load_dotenv
from urllib.parse import quote
# ---------------------------------------------------------------------------
# Load .env (for OPENWEATHER_API_KEY)
# ---------------------------------------------------------------------------
plugin_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(plugin_dir)
dotenv_path = os.path.join(parent_dir, ".env")
load_dotenv(dotenv_path)
from plugins.common import html_escape, collapsible_summary, code_block
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
# ---------------------------------------------------------------------------
# WMO codes → description + emoji (for OpenMeteo)
# ---------------------------------------------------------------------------
WMO_CODES = {
0: ("Clear sky", "☀️"),
1: ("Mainly clear", "🌤️"),
2: ("Partly cloudy", ""),
3: ("Overcast", "☁️"),
45: ("Fog", "🌫️"),
48: ("Depositing rime fog", "🌫️"),
51: ("Light drizzle", "🌦️"),
53: ("Moderate drizzle", "🌦️"),
55: ("Dense drizzle", "🌧️"),
56: ("Light freezing drizzle", "🌧️"),
57: ("Dense freezing drizzle", "🌧️"),
61: ("Slight rain", "🌧️"),
63: ("Moderate rain", "🌧️"),
65: ("Heavy rain", "🌧️"),
66: ("Light freezing rain", "🌧️"),
67: ("Heavy freezing rain", "🌧️"),
71: ("Slight snow fall", "❄️"),
73: ("Moderate snow fall", "❄️"),
75: ("Heavy snow fall", "❄️"),
77: ("Snow grains", "❄️"),
80: ("Slight rain showers", "🌦️"),
81: ("Moderate rain showers", "🌧️"),
82: ("Violent rain showers", "🌧️"),
85: ("Slight snow showers", "🌨️"),
86: ("Heavy snow showers", "🌨️"),
95: ("Thunderstorm", "⛈️"),
96: ("Thunderstorm with slight hail", "⛈️"),
99: ("Thunderstorm with heavy hail", "⛈️"),
}
# ---------------------------------------------------------------------------
# Primary: OpenWeatherMap
# OpenWeatherMap helpers
# ---------------------------------------------------------------------------
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
"""Fetch current weather from OpenWeatherMap. Returns None on failure."""
if not OPENWEATHER_API_KEY:
logging.info("OpenWeatherMap key missing, skipping primary")
return None
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
params = {
"q": location,
"appid": OPENWEATHER_API_KEY,
"units": "metric", # Celsius
"units": "metric",
}
try:
async with session.get(url, params=params, timeout=10) as resp:
@@ -83,46 +36,10 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
logging.warning(f"OpenWeatherMap request error: {e}")
return None
def format_openweathermap(data: dict) -> str:
"""Build the one-line weather message from OpenWeatherMap data."""
city = data.get("name", "Unknown")
sys_data = data.get("sys", {})
country = sys_data.get("country", "")
main_data = data.get("main", {})
temp_c = main_data.get("temp", 0)
temp_f = round(temp_c * 9 / 5 + 32, 1)
humidity = main_data.get("humidity", 0)
weather_list = data.get("weather", [])
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
emoji = "🌡️"
if weather_list:
wmain = weather_list[0].get("main", "")
emoji = {
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
}.get(wmain, "🌡️")
wind = data.get("wind", {}).get("speed", 0)
return (
f"<strong>[{emoji} Weather for {city}, {country}]</strong>: "
f"<strong>Condition:</strong> {description} | "
f"<strong>Temperature:</strong> {temp_c:.1f}°C ({temp_f:.1f}°F) | "
f"<strong>Humidity:</strong> {humidity}% | "
f"<strong>Wind Speed:</strong> {wind} m/s"
)
# ---------------------------------------------------------------------------
# Fallback: OpenMeteo (no key, free)
# OpenMeteo helpers (fallback)
# ---------------------------------------------------------------------------
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
"""Geocode a city name via OpenMeteo. Returns location info dict or None."""
url = "https://geocoding-api.open-meteo.com/v1/search"
params = {"name": location, "count": 1, "language": "en"}
try:
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
logging.warning(f"OpenMeteo geocode error: {e}")
return None
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
timezone: str = "auto") -> dict | None:
"""Fetch current weather from OpenMeteo. Returns JSON or None."""
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
logging.warning(f"OpenMeteo weather error: {e}")
return None
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
def format_openweathermap(data: dict) -> str:
"""Build a code block from OpenWeatherMap response."""
city = data.get("name", "Unknown")
sys_data = data.get("sys", {})
country = sys_data.get("country", "")
main = data.get("main", {})
temp_c = main.get("temp", 0)
temp_f = round(temp_c * 9 / 5 + 32, 1)
humidity = main.get("humidity", 0)
wind_speed = data.get("wind", {}).get("speed", 0)
weather_list = data.get("weather", [])
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
emoji_map = {
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
}
main_weather = weather_list[0].get("main", "") if weather_list else ""
weather_emoji = emoji_map.get(main_weather, "🌡️")
location = f"{city}, {country}" if country else city
rows = [
("🌍", "Location", location),
(weather_emoji, "Condition", description),
("🌡️", "Temperature", f"{temp_c:.1f}°C / {temp_f:.1f}°F"),
("💧", "Humidity", f"{humidity}%"),
("💨", "Wind Speed", f"{wind_speed} m/s"),
]
sections = [{"title": "", "rows": rows}]
return code_block(f"🌤️ Weather for {location}", sections)
def format_meteo(loc_info: dict, weather_data: dict) -> str:
"""Format OpenMeteo result into the same oneline style."""
"""Build a code block from OpenMeteo response."""
c = weather_data["current_weather"]
code = c["weathercode"]
desc, emoji = WMO_CODES.get(code, ("Unknown", "🌡️"))
wmo_emoji = {
0: ("Clear sky", "☀️"),
1: ("Mainly clear", "🌤️"),
2: ("Partly cloudy", ""),
3: ("Overcast", "☁️"),
45: ("Fog", "🌫️"),
51: ("Light drizzle", "🌦️"),
61: ("Slight rain", "🌧️"),
63: ("Moderate rain", "🌧️"),
71: ("Slight snow", "❄️"),
95: ("Thunderstorm", "⛈️"),
}
desc, emoji = wmo_emoji.get(code, ("Unknown", "🌡️"))
city = loc_info["name"]
country = loc_info.get("country", "")
state = loc_info.get("state", "")
# Build location string
parts = [city]
if state and state != city:
parts.append(state)
if country:
parts.append(country)
loc_str = ", ".join(parts)
location_parts = [loc_info["name"]]
if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
location_parts.append(loc_info["state"])
if loc_info.get("country"):
location_parts.append(loc_info["country"])
location = ", ".join(location_parts)
temp_f = c["temperature"]
temp_c = round((temp_f - 32) * 5 / 9, 1)
wind = c["windspeed"]
wind = c["windspeed"] # mph
return (
f"<strong>[{emoji} Weather for {loc_str}]</strong>: "
f"<strong>Condition:</strong> {desc} | "
f"<strong>Temperature:</strong> {temp_c}°C ({temp_f}°F) | "
f"<strong>Wind Speed:</strong> {wind} mph"
)
rows = [
("🌍", "Location", location),
(emoji, "Condition", desc),
("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
("💨", "Wind Speed", f"{wind} mph"),
]
sections = [{"title": "", "rows": rows}]
return code_block(f"🌤️ Weather for {location}", sections)
# ---------------------------------------------------------------------------
@@ -218,14 +179,11 @@ async def handle_command(room, message, bot, prefix, config):
async with aiohttp.ClientSession() as session:
# 1. Try OpenWeatherMap
owm_data = await openweathermap_get(session, location)
if owm_data:
if owm_data.get("cod") == 200:
msg = format_openweathermap(owm_data)
await bot.api.send_markdown_message(room.room_id, msg)
logging.info("Sent weather via OpenWeatherMap")
return
# OpenWeatherMap returned an error status inside JSON (e.g., 401, 404)
logging.info("OpenWeatherMap returned error code %s, falling back", owm_data.get("cod"))
if owm_data and owm_data.get("cod") == 200:
block = format_openweathermap(owm_data)
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, output)
return
# 2. Fallback: OpenMeteo
logging.info("Falling back to OpenMeteo")
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
if not loc_info:
await bot.api.send_text_message(
room.room_id,
f"Location '{location}' not found."
f"Location '{html_escape(location)}' not found."
)
return
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
)
return
msg = format_meteo(loc_info, wdata)
await bot.api.send_markdown_message(room.room_id, msg)
block = format_meteo(loc_info, wdata)
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent weather via OpenMeteo (fallback)")
# ---------------------------------------------------------------------------
# Plugin setup
# ---------------------------------------------------------------------------
def setup(bot):
logging.info("Weather plugin loaded (OpenWeatherMap + OpenMeteo fallback)")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "Weather forecast (OWM primary, OpenMeteo fallback)"
__description__ = "Weather data plugin"
__help__ = """
<details>
<summary><strong>!weather</strong> Current weather</summary>
<p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind.<br>
Uses OpenWeatherMap if a valid API key is present; falls back to free OpenMeteo otherwise.</p>
<p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, OpenMeteo fallback.</p>
</details>
"""