Files
discordbot/cogs/calendar.py
T
2026-07-13 00:41:57 +03:00

191 lines
6.8 KiB
Python

"""Calendar cog — modal-based booking, country-aware timezones."""
######################################################################
import logging
import re
from datetime import datetime, timezone, timedelta
import aiohttp
import discord
from discord import app_commands
from discord.ext import commands
from .calendar_data import COUNTRY_TZ
log = logging.getLogger(__name__)
FIRESTORE_CALENDAR = (
"https://firestore.googleapis.com/v1/projects/esef-514bf"
"/databases/(default)/documents/calendar_events"
)
DATE_RE = re.compile(r"^\d{2}-\d{2}-\d{4}$")
TIME_RE = re.compile(r"^\d{2}:\d{2}$")
# Default timezone: South Africa (UTC+2)
DEFAULT_COUNTRY = "South Africa"
DEFAULT_TITLE = "Demo Review"
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
class BookModal(discord.ui.Modal, title="Book an Event"):
"""Popup form for booking calendar events."""
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,
)
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:
day, month, year = date.split("-")
datetime(int(year), int(month), int(day))
datetime.strptime(time_str, "%H:%M")
except ValueError:
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,
)
tz = timezone(timedelta(hours=offset))
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},
"date": {"stringValue": f"{year}-{month}-{day}"},
"time": {"stringValue": time_str},
"country": {"stringValue": country},
"utc": {"stringValue": utc_ts},
"description": {"stringValue": description},
"createdBy": {"stringValue": str(interaction.user)},
}
}
async with aiohttp.ClientSession() as session:
async with session.post(FIRESTORE_CALENDAR, json=payload) as resp:
if resp.status in (200, 201):
desc = f" — {description}" if description else ""
await interaction.response.send_message(
f"📅 Booked **{date} at {time_str} ({country})**: **{title}**{desc}"
)
else:
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 (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: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:
async with session.get(FIRESTORE_CALENDAR) as resp:
if resp.status != 200:
return await interaction.followup.send("❌ Could not fetch events.")
data = await resp.json()
for doc in data.get("documents", []):
fields = doc.get("fields", {})
d = fields.get("date", {}).get("stringValue", "")
t = fields.get("title", {}).get("stringValue", "")
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:
deleted = True
break
if deleted:
await interaction.followup.send(f"🗑️ Removed **{title}** from {date}.")
else:
await interaction.followup.send(f"❌ No event **{title}** found on {date}.")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Calendar(bot))