various plugin refactors and fixes
This commit is contained in:
+117
-131
@@ -1,210 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Time Zone Plugin – completely hardcoded-free using Open-Meteo APIs.
|
||||
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 urllib.parse import quote
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
def format_ampm(dt_str: str) -> str:
|
||||
"""Convert ISO datetime to AM/PM format."""
|
||||
# -------------------------------------------------------------------
|
||||
# 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:
|
||||
if '+' in dt_str:
|
||||
dt_str = dt_str.split('+')[0]
|
||||
if '.' in dt_str:
|
||||
dt_str = dt_str.split('.')[0]
|
||||
dt_str = dt_str.replace('T', ' ')
|
||||
dt = datetime.fromisoformat(dt_str)
|
||||
return dt.strftime("%I:%M:%S %p").lstrip("0")
|
||||
except:
|
||||
return dt_str
|
||||
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
|
||||
|
||||
async def geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
||||
"""
|
||||
Open-Meteo Geocoding API (free, no key, no hardcoding).
|
||||
Returns (latitude, longitude, display_name) or 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()
|
||||
if data.get("results") and len(data["results"]) > 0:
|
||||
result = data["results"][0]
|
||||
lat = result["latitude"]
|
||||
lon = result["longitude"]
|
||||
name = result.get("name", city)
|
||||
country = result.get("country", "")
|
||||
admin1 = result.get("admin1", "")
|
||||
|
||||
# Build display name: "Lahore, Punjab, Pakistan"
|
||||
display_parts = [name]
|
||||
if admin1 and admin1 != name:
|
||||
display_parts.append(admin1)
|
||||
if country:
|
||||
display_parts.append(country)
|
||||
display_name = ", ".join(display_parts)
|
||||
|
||||
logging.info(f"Geocoded: {city} → {display_name} ({lat}, {lon})")
|
||||
return lat, lon, display_name
|
||||
else:
|
||||
logging.warning(f"Geocoding API HTTP {resp.status} for {city}")
|
||||
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 get_timezone(lat: float, lon: float) -> str | None:
|
||||
"""
|
||||
Get timezone name from coordinates using timezonedb (free tier, no key).
|
||||
Alternative: use Open-Meteo's time API directly.
|
||||
"""
|
||||
# Open-Meteo's time API accepts coordinates directly
|
||||
# We'll use this instead of timezonedb
|
||||
return None # Will be handled in fetch_time_by_coords
|
||||
|
||||
async def fetch_time_by_coords(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||||
async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||||
"""
|
||||
Get current time using Open-Meteo (no key required).
|
||||
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&timeformat=unixtime"
|
||||
|
||||
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", {})
|
||||
timezone = data.get("timezone", "Unknown")
|
||||
unixtime = current.get("time")
|
||||
temperature = current.get("temperature")
|
||||
|
||||
if unixtime:
|
||||
# Convert UNIX timestamp to datetime
|
||||
dt = datetime.fromtimestamp(unixtime)
|
||||
time_str = current.get("time") # ISO 8601, local time
|
||||
temp_c = current.get("temperature")
|
||||
tz = data.get("timezone", "Unknown")
|
||||
if time_str:
|
||||
return {
|
||||
"datetime": dt.isoformat(),
|
||||
"timezone": timezone,
|
||||
"temperature": temperature
|
||||
"datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
|
||||
"timezone": tz,
|
||||
"temperature": temp_c
|
||||
}
|
||||
except Exception as e:
|
||||
logging.warning(f"Time fetch error: {e}")
|
||||
logging.warning(f"Weather fetch error: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_time_by_zone(session: aiohttp.ClientSession, zone: str) -> dict | None:
|
||||
"""Get current time for a named timezone using Open-Meteo."""
|
||||
# Open-Meteo doesn't have named timezone endpoint, need to geocode a representative city
|
||||
# Fallback to worldtimeapi.org for IANA zones
|
||||
url = f"http://worldtimeapi.org/api/timezone/{zone}"
|
||||
try:
|
||||
async with session.get(url, timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logging.warning(f"Timezone API error: {e}")
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Main resolver
|
||||
# -------------------------------------------------------------------
|
||||
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
|
||||
"""Main resolution: geocode any city, then get time."""
|
||||
query = query.strip().lower()
|
||||
"""Return (data_dict, display_name) or (None, error_message)."""
|
||||
query = query.strip()
|
||||
|
||||
# Check if it's an IANA zone (contains '/')
|
||||
if '/' in query or query in ("utc", "gmt"):
|
||||
data = await fetch_time_by_zone(session, query)
|
||||
# 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()
|
||||
return None, f"Timezone '{query}' not found"
|
||||
else:
|
||||
return None, f"Timezone '{html_escape(query)}' not recognised."
|
||||
|
||||
# Geocode the city (no hardcoding!)
|
||||
geocode_result = await geocode_city(session, query)
|
||||
# 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 '{query}'. Try being more specific."
|
||||
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)}'."
|
||||
|
||||
# Get time from coordinates
|
||||
data = await fetch_time_by_coords(session, lat, lon)
|
||||
if not data:
|
||||
return None, f"Could not get time for '{display_name}'"
|
||||
|
||||
return data, display_name
|
||||
|
||||
def format_response(data: dict, display_name: str) -> str:
|
||||
"""Format time data into HTML."""
|
||||
# -------------------------------------------------------------------
|
||||
# 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", "")
|
||||
local_time = format_ampm(raw_time) if raw_time else "Unknown"
|
||||
tz = data.get("timezone", "Unknown")
|
||||
# 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")
|
||||
temp_str = f"<br>🌡️ <strong>Temperature:</strong> {temp}°C" if temp is not None else ""
|
||||
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"
|
||||
|
||||
return f"""
|
||||
<details>
|
||||
<summary><strong>🕒 Time in {display_name}</strong></summary>
|
||||
<p>
|
||||
📍 <strong>Timezone:</strong> {tz}<br>
|
||||
📅 <strong>Local time:</strong> {local_time}{temp_str}
|
||||
</p>
|
||||
</details>
|
||||
"""
|
||||
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)
|
||||
|
||||
def help_text() -> str:
|
||||
return """
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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., Europe/London, Asia/Karachi<br>
|
||||
<strong>!time help</strong> – Show this help<br><br>
|
||||
<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 Paris</code><br>
|
||||
<code>!time Asia/Karachi</code><br><br>
|
||||
<em>No city names are hardcoded. The bot uses Open-Meteo's geocoding API.</em>
|
||||
<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 (zero hardcoded cities) loaded.")
|
||||
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_text())
|
||||
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: {query}...")
|
||||
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
|
||||
await bot.api.send_markdown_message(room.room_id, format_response(data, display))
|
||||
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}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "World clock (no hardcoded cities)"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!time</strong> – Current time for any city</summary>
|
||||
<ul>
|
||||
<li><code>!time <city></code> – Geocode any city (free Open-Meteo API)</li>
|
||||
<li><code>!time <IANA zone></code> – e.g., <code>Europe/London</code></li>
|
||||
</ul>
|
||||
<p>Also shows current temperature if available.</p>
|
||||
</details>
|
||||
"""
|
||||
__description__ = "World clock (offline IANA zones + free geocoding)"
|
||||
__help__ = _HELP_MD
|
||||
|
||||
Reference in New Issue
Block a user