calendar polish
This commit is contained in:
1 parent
f8c7fc4fc6
commit
56168b5251
1 file changed
+114
-61
+114
-61
@@ -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 logging
|
||||||
import re
|
import re
|
||||||
@@ -9,6 +9,8 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from .calendar_data import COUNTRY_TZ
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
FIRESTORE_CALENDAR = (
|
FIRESTORE_CALENDAR = (
|
||||||
@@ -16,67 +18,112 @@ FIRESTORE_CALENDAR = (
|
|||||||
"/databases/(default)/documents/calendar_events"
|
"/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}$")
|
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):
|
def _find_offset(country: str) -> float | None:
|
||||||
"""Book and manage calendar events from Discord."""
|
"""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:
|
class BookModal(discord.ui.Modal, title="Book an Event"):
|
||||||
try:
|
"""Popup form for booking calendar events."""
|
||||||
await self.bot.tree.sync()
|
|
||||||
log.info("Calendar slash commands synced")
|
|
||||||
except Exception:
|
|
||||||
log.warning("Failed to sync slash commands", exc_info=True)
|
|
||||||
|
|
||||||
@app_commands.command(name="book", description="Add an event to the calendar")
|
date = discord.ui.TextInput(
|
||||||
@app_commands.describe(
|
label="Date (DD-MM-YYYY)",
|
||||||
date="Date: YYYY-MM-DD (e.g. 2026-07-20)",
|
placeholder="20-07-2026",
|
||||||
time="Time: HH:MM 24-hour format (e.g. 14:30)",
|
min_length=10, max_length=10,
|
||||||
country="What country's time is this? (default: Turkey)",
|
)
|
||||||
title="Event title",
|
time = discord.ui.TextInput(
|
||||||
description="Extra notes (optional)",
|
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):
|
async def on_submit(self, interaction: discord.Interaction) -> None:
|
||||||
return await interaction.followup.send("❌ Format: `/book date:2026-07-20 time:14:30 country:Turkey title:Event`")
|
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:
|
try:
|
||||||
datetime.strptime(date, "%Y-%m-%d")
|
day, month, year = date.split("-")
|
||||||
datetime.strptime(time, "%H:%M")
|
datetime(int(year), int(month), int(day))
|
||||||
|
datetime.strptime(time_str, "%H:%M")
|
||||||
except ValueError:
|
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))
|
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()
|
utc_ts = local_dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"fields": {
|
"fields": {
|
||||||
"title": {"stringValue": title.strip()},
|
"title": {"stringValue": title},
|
||||||
"date": {"stringValue": date},
|
"date": {"stringValue": f"{year}-{month}-{day}"},
|
||||||
"time": {"stringValue": time},
|
"time": {"stringValue": time_str},
|
||||||
"country": {"stringValue": country},
|
"country": {"stringValue": country},
|
||||||
"tzOffset": {"doubleValue": offset},
|
|
||||||
"utc": {"stringValue": utc_ts},
|
"utc": {"stringValue": utc_ts},
|
||||||
"description": {"stringValue": description.strip()},
|
"description": {"stringValue": description},
|
||||||
"createdBy": {"stringValue": str(interaction.user)},
|
"createdBy": {"stringValue": str(interaction.user)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,29 +132,35 @@ class Calendar(commands.Cog):
|
|||||||
async with session.post(FIRESTORE_CALENDAR, json=payload) as resp:
|
async with session.post(FIRESTORE_CALENDAR, json=payload) as resp:
|
||||||
if resp.status in (200, 201):
|
if resp.status in (200, 201):
|
||||||
desc = f" — {description}" if description else ""
|
desc = f" — {description}" if description else ""
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
f"📅 Booked **{date} at {time} ({country})**: **{title}**{desc}"
|
f"📅 Booked **{date} at {time_str} ({country})**: **{title}**{desc}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
body = await resp.text()
|
await interaction.response.send_message(
|
||||||
log.warning("Firestore POST failed %d: %s", resp.status, body[:200])
|
"❌ Failed to save. Try again.", ephemeral=True
|
||||||
await interaction.followup.send("❌ Failed to save event.")
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.command(name="unbook", description="Remove an event")
|
||||||
@app_commands.describe(
|
@app_commands.describe(date="Date (DD-MM-YYYY)", title="Exact title to remove")
|
||||||
date="Date of the event (YYYY-MM-DD)",
|
async def unbook_cmd(self, interaction: discord.Interaction, date: str, title: str) -> None:
|
||||||
title="Exact title to remove",
|
await interaction.response.defer(ephemeral=True)
|
||||||
)
|
|
||||||
async def unbook_cmd(
|
|
||||||
self,
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
date: str,
|
|
||||||
title: str,
|
|
||||||
) -> None:
|
|
||||||
await interaction.response.defer()
|
|
||||||
|
|
||||||
if not DATE_RE.match(date) or not title.strip():
|
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
|
deleted = False
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@@ -120,7 +173,7 @@ class Calendar(commands.Cog):
|
|||||||
fields = doc.get("fields", {})
|
fields = doc.get("fields", {})
|
||||||
d = fields.get("date", {}).get("stringValue", "")
|
d = fields.get("date", {}).get("stringValue", "")
|
||||||
t = fields.get("title", {}).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]
|
doc_name = doc["name"].split("/")[-1]
|
||||||
async with session.delete(f"{FIRESTORE_CALENDAR}/{doc_name}") as dresp:
|
async with session.delete(f"{FIRESTORE_CALENDAR}/{doc_name}") as dresp:
|
||||||
if dresp.status == 200:
|
if dresp.status == 200:
|
||||||
|
|||||||
Reference in new issue
Block a user