197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Time Zone Plugin – uses pytz for IANA zones and Open‑Meteo for city geocoding.
|
||
Outputs a clean code block with emojis and aligned columns via shared code_block.
|
||
"""
|
||
|
||
import logging
|
||
import aiohttp
|
||
import simplematrixbotlib as botlib
|
||
from datetime import datetime
|
||
import pytz
|
||
from plugins.common import collapsible_summary, html_escape, code_block
|
||
|
||
# -------------------------------------------------------------------
|
||
# Offline helper for IANA timezone names
|
||
# -------------------------------------------------------------------
|
||
def _get_time_for_iana_zone(zone: str) -> dict | None:
|
||
"""Return a dict with datetime, timezone, and optional temperature using pytz."""
|
||
try:
|
||
tz = pytz.timezone(zone)
|
||
now = datetime.now(tz)
|
||
return {
|
||
"datetime": now.isoformat(),
|
||
"timezone": zone,
|
||
"temperature": None # no weather for zone lookups
|
||
}
|
||
except pytz.UnknownTimeZoneError:
|
||
return None
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# Online helpers (Open‑Meteo)
|
||
# -------------------------------------------------------------------
|
||
async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
||
"""Geocode a city name via Open‑Meteo. Returns (lat, lon, display_name) or None."""
|
||
from urllib.parse import quote
|
||
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
|
||
try:
|
||
async with session.get(url, timeout=10) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
results = data.get("results", [])
|
||
if results:
|
||
r = results[0]
|
||
lat = float(r["latitude"])
|
||
lon = float(r["longitude"])
|
||
name = r.get("name", city)
|
||
country = r.get("country", "")
|
||
admin1 = r.get("admin1", "")
|
||
display = ", ".join(filter(None, [name, admin1, country]))
|
||
return lat, lon, display
|
||
except Exception as e:
|
||
logging.warning(f"Geocoding error: {e}")
|
||
return None
|
||
|
||
|
||
async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||
"""
|
||
Fetch current time and temperature from Open‑Meteo (free, no key).
|
||
The API returns an ISO 8601 string for the current time.
|
||
"""
|
||
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto"
|
||
try:
|
||
async with session.get(url, timeout=10) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
current = data.get("current_weather", {})
|
||
time_str = current.get("time") # ISO 8601, local time
|
||
temp_c = current.get("temperature")
|
||
tz = data.get("timezone", "Unknown")
|
||
if time_str:
|
||
return {
|
||
"datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
|
||
"timezone": tz,
|
||
"temperature": temp_c
|
||
}
|
||
except Exception as e:
|
||
logging.warning(f"Weather fetch error: {e}")
|
||
return None
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# Main resolver
|
||
# -------------------------------------------------------------------
|
||
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
|
||
"""Return (data_dict, display_name) or (None, error_message)."""
|
||
query = query.strip()
|
||
|
||
# 1. Try as IANA zone (offline, always works)
|
||
if '/' in query or query.lower() in ("utc", "gmt"):
|
||
data = _get_time_for_iana_zone(query)
|
||
if data:
|
||
return data, query.upper()
|
||
else:
|
||
return None, f"Timezone '{html_escape(query)}' not recognised."
|
||
|
||
# 2. Otherwise geocode as a city name
|
||
geocode_result = await _geocode_city(session, query)
|
||
if not geocode_result:
|
||
return None, f"Could not find city '{html_escape(query)}'. Try a more specific name or use an IANA zone."
|
||
|
||
lat, lon, display_name = geocode_result
|
||
weather_data = await _fetch_weather(session, lat, lon)
|
||
if weather_data:
|
||
return weather_data, display_name
|
||
return None, f"Could not fetch time/weather for '{html_escape(display_name)}'."
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# Formatting – uses shared code_block from common.py
|
||
# -------------------------------------------------------------------
|
||
def _format_time_output(data: dict, display_name: str) -> str:
|
||
"""Convert time data into a code block via the shared formatter."""
|
||
raw_time = data.get("datetime", "")
|
||
# Convert ISO string to AM/PM format
|
||
try:
|
||
if '+' in raw_time:
|
||
raw_time = raw_time.split('+')[0]
|
||
dt = datetime.fromisoformat(raw_time)
|
||
local_time = dt.strftime("%I:%M:%S %p").lstrip("0")
|
||
except Exception:
|
||
local_time = raw_time
|
||
|
||
tz_display = data.get("timezone", "Unknown")
|
||
temp = data.get("temperature")
|
||
if temp is not None:
|
||
temp_f = round(temp * 9/5 + 32, 1)
|
||
temp_str = f"{temp:.1f}°C / {temp_f:.1f}°F"
|
||
else:
|
||
temp_str = "N/A"
|
||
|
||
rows = [
|
||
("🌐", "Location", display_name),
|
||
("🕒", "Local Time", local_time),
|
||
("📅", "Timezone", tz_display),
|
||
("🌡️", "Temperature", temp_str),
|
||
]
|
||
# Wrap rows in a single section with no title (title is part of code_block's main title)
|
||
sections = [{"title": "", "rows": rows}]
|
||
return code_block("🕒 Time Info", sections)
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# Help
|
||
# -------------------------------------------------------------------
|
||
_HELP_MD = """
|
||
<details>
|
||
<summary><strong>🕒 Time Plugin Help</strong></summary>
|
||
<p><strong>!time <any city></strong> – Get current time for ANY city worldwide<br>
|
||
<strong>!time <IANA zone></strong> – e.g., <code>Europe/London</code>, <code>Asia/Karachi</code><br>
|
||
<strong>!time help</strong> – Show this help<br>
|
||
<strong>Examples:</strong><br>
|
||
<code>!time Lahore</code><br>
|
||
<code>!time New York</code><br>
|
||
<code>!time Europe/London</code><br>
|
||
<em>No city names are hardcoded. IANA zones work completely offline.</em>
|
||
</p>
|
||
</details>
|
||
"""
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# Plugin lifecycle
|
||
# -------------------------------------------------------------------
|
||
def setup(bot):
|
||
logging.info("Time plugin (offline IANA zones + Open‑Meteo cities) loaded.")
|
||
|
||
async def handle_command(room, message, bot, prefix, config):
|
||
import simplematrixbotlib as botlib
|
||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
|
||
return
|
||
|
||
args = match.args()
|
||
if not args or args[0].lower() == "help":
|
||
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
|
||
return
|
||
|
||
query = " ".join(args).strip()
|
||
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {html_escape(query)}...")
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
data, display = await resolve_time(session, query)
|
||
if data is None:
|
||
await bot.api.send_text_message(room.room_id, f"❌ {display}")
|
||
return
|
||
block = _format_time_output(data, display)
|
||
output = collapsible_summary(f"🕒 Time in {html_escape(display)}", block)
|
||
await bot.api.send_markdown_message(room.room_id, output)
|
||
logging.info(f"Time sent for {query}")
|
||
|
||
|
||
__version__ = "1.1.2"
|
||
__author__ = "Funguy Bot"
|
||
__description__ = "World clock (offline IANA zones + free geocoding)"
|
||
__help__ = _HELP_MD
|