From 56168b52516e5062c2644bc0f517a3de6fa61610 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Mon, 13 Jul 2026 00:41:57 +0300 Subject: [PATCH] calendar polish --- cogs/calendar.py | 175 ++++++++++++++++++++++++++++++----------------- 1 file changed, 114 insertions(+), 61 deletions(-) diff --git a/cogs/calendar.py b/cogs/calendar.py index f76f6f4..324a604 100644 --- a/cogs/calendar.py +++ b/cogs/calendar.py @@ -1,4 +1,4 @@ -"""Calendar cog — book events via Discord slash commands; website auto-converts timezones.""" +"""Calendar cog — modal-based booking, country-aware timezones.""" ###################################################################### import logging import re @@ -9,6 +9,8 @@ import discord from discord import app_commands from discord.ext import commands +from .calendar_data import COUNTRY_TZ + log = logging.getLogger(__name__) FIRESTORE_CALENDAR = ( @@ -16,67 +18,112 @@ FIRESTORE_CALENDAR = ( "/databases/(default)/documents/calendar_events" ) -DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +DATE_RE = re.compile(r"^\d{2}-\d{2}-\d{4}$") TIME_RE = re.compile(r"^\d{2}:\d{2}$") -from .calendar_data import COUNTRY_TZ, country_autocomplete +# Default timezone: South Africa (UTC+2) +DEFAULT_COUNTRY = "South Africa" +DEFAULT_TITLE = "Demo Review" -class Calendar(commands.Cog): - """Book and manage calendar events from Discord.""" +def _find_offset(country: str) -> float | None: + """Look up a country's UTC offset. Case-insensitive fuzzy match.""" + c = country.strip().lower() + if not c: + return None + # Exact match + for name, offset in COUNTRY_TZ.items(): + if c == name.lower(): + return offset + # Starts with + for name, offset in COUNTRY_TZ.items(): + if name.lower().startswith(c): + return offset + # Substring anywhere + for name, offset in COUNTRY_TZ.items(): + if c in name.lower(): + return offset + return None - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - async def cog_load(self) -> None: - try: - await self.bot.tree.sync() - log.info("Calendar slash commands synced") - except Exception: - log.warning("Failed to sync slash commands", exc_info=True) +class BookModal(discord.ui.Modal, title="Book an Event"): + """Popup form for booking calendar events.""" - @app_commands.command(name="book", description="Add an event to the calendar") - @app_commands.describe( - date="Date: YYYY-MM-DD (e.g. 2026-07-20)", - time="Time: HH:MM 24-hour format (e.g. 14:30)", - country="What country's time is this? (default: Turkey)", - title="Event title", - description="Extra notes (optional)", + date = discord.ui.TextInput( + label="Date (DD-MM-YYYY)", + placeholder="20-07-2026", + min_length=10, max_length=10, + ) + time = discord.ui.TextInput( + label="Time (24h, e.g. 14:30)", + placeholder="14:30", + min_length=5, max_length=5, + ) + title = discord.ui.TextInput( + label="Title", + placeholder="Demo Review", + default="Demo Review", + max_length=80, + ) + country = discord.ui.TextInput( + label="Country / Timezone", + placeholder="South Africa", + default="South Africa", + max_length=30, + ) + description = discord.ui.TextInput( + label="Description (optional)", + placeholder="Notes...", + max_length=200, required=False, ) - @app_commands.autocomplete(country=country_autocomplete) - async def book_cmd( - self, - interaction: discord.Interaction, - date: str, - time: str, - title: str, - country: str = "Turkey", - description: str = "", - ) -> None: - await interaction.response.defer() - if not DATE_RE.match(date) or not TIME_RE.match(time): - return await interaction.followup.send("❌ Format: `/book date:2026-07-20 time:14:30 country:Turkey title:Event`") + async def on_submit(self, interaction: discord.Interaction) -> None: + date = self.date.value.strip() + time_str = self.time.value.strip() + title = self.title.value.strip() + country = self.country.value.strip() + description = self.description.value.strip() + + if not DATE_RE.match(date): + return await interaction.response.send_message( + "❌ Date must be `DD-MM-YYYY`, e.g. `20-07-2026`", ephemeral=True + ) + if not TIME_RE.match(time_str): + return await interaction.response.send_message( + "❌ Time must be `HH:MM` (24h), e.g. `14:30`", ephemeral=True + ) try: - datetime.strptime(date, "%Y-%m-%d") - datetime.strptime(time, "%H:%M") + day, month, year = date.split("-") + datetime(int(year), int(month), int(day)) + datetime.strptime(time_str, "%H:%M") except ValueError: - return await interaction.followup.send("❌ Invalid date or time.") + return await interaction.response.send_message( + "❌ That date doesn't exist.", ephemeral=True + ) + + offset = _find_offset(country) + if offset is None: + return await interaction.response.send_message( + f"❌ Unknown country. Try: Turkey, UK, US East, Japan, etc.\n" + f"Type a country name from the list.", + ephemeral=True, + ) - offset = COUNTRY_TZ.get(country, 3) tz = timezone(timedelta(hours=offset)) - local_dt = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) + local_dt = datetime( + int(year), int(month), int(day), + *map(int, time_str.split(":")), + ).replace(tzinfo=tz) utc_ts = local_dt.astimezone(timezone.utc).isoformat() payload = { "fields": { - "title": {"stringValue": title.strip()}, - "date": {"stringValue": date}, - "time": {"stringValue": time}, + "title": {"stringValue": title}, + "date": {"stringValue": f"{year}-{month}-{day}"}, + "time": {"stringValue": time_str}, "country": {"stringValue": country}, - "tzOffset": {"doubleValue": offset}, "utc": {"stringValue": utc_ts}, - "description": {"stringValue": description.strip()}, + "description": {"stringValue": description}, "createdBy": {"stringValue": str(interaction.user)}, } } @@ -85,29 +132,35 @@ class Calendar(commands.Cog): async with session.post(FIRESTORE_CALENDAR, json=payload) as resp: if resp.status in (200, 201): desc = f" — {description}" if description else "" - await interaction.followup.send( - f"📅 Booked **{date} at {time} ({country})**: **{title}**{desc}" + await interaction.response.send_message( + f"📅 Booked **{date} at {time_str} ({country})**: **{title}**{desc}" ) else: - body = await resp.text() - log.warning("Firestore POST failed %d: %s", resp.status, body[:200]) - await interaction.followup.send("❌ Failed to save event.") + await interaction.response.send_message( + "❌ Failed to save. Try again.", ephemeral=True + ) + + +class Calendar(commands.Cog): + """Book events via a popup form.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="book", description="Open a form to add a calendar event") + async def book_cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal(BookModal()) @app_commands.command(name="unbook", description="Remove an event") - @app_commands.describe( - date="Date of the event (YYYY-MM-DD)", - title="Exact title to remove", - ) - async def unbook_cmd( - self, - interaction: discord.Interaction, - date: str, - title: str, - ) -> None: - await interaction.response.defer() + @app_commands.describe(date="Date (DD-MM-YYYY)", title="Exact title to remove") + async def unbook_cmd(self, interaction: discord.Interaction, date: str, title: str) -> None: + await interaction.response.defer(ephemeral=True) if not DATE_RE.match(date) or not title.strip(): - return await interaction.followup.send("❌ Usage: `/unbook date:2026-07-20 title:Event`") + return await interaction.followup.send("❌ Usage: `/unbook date:20-07-2026 title:Demo Review`") + + day, month, year = date.split("-") + iso_date = f"{year}-{month}-{day}" deleted = False async with aiohttp.ClientSession() as session: @@ -120,7 +173,7 @@ class Calendar(commands.Cog): fields = doc.get("fields", {}) d = fields.get("date", {}).get("stringValue", "") t = fields.get("title", {}).get("stringValue", "") - if d == date and t.lower() == title.strip().lower(): + if d == iso_date and t.lower() == title.strip().lower(): doc_name = doc["name"].split("/")[-1] async with session.delete(f"{FIRESTORE_CALENDAR}/{doc_name}") as dresp: if dresp.status == 200: