Files
FunguyBot/plugins/timezone.py
T

197 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Time Zone Plugin uses pytz for IANA zones and OpenMeteo 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 (OpenMeteo)
# -------------------------------------------------------------------
async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
"""Geocode a city name via OpenMeteo. 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 OpenMeteo (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}&current_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 &lt;any city&gt;</strong> Get current time for ANY city worldwide<br>
<strong>!time &lt;IANA zone&gt;</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 + OpenMeteo 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