Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions capy_discord/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class CreateEventRequest(TypedDict, total=False):
"""Represents event creation payloads."""

org_id: Required[str]
title: str
description: str
event_time: str
location: str
Expand All @@ -73,6 +74,7 @@ class CreateEventRequest(TypedDict, total=False):
class UpdateEventRequest(TypedDict, total=False):
"""Represents event update payloads."""

title: str
description: str
event_time: str
location: str
Expand All @@ -89,6 +91,7 @@ class EventResponse(TypedDict, total=False):
"""Represents event response payloads."""

eid: str
title: str
description: str
event_time: str
location: str
Expand Down Expand Up @@ -133,6 +136,13 @@ class CreateOrganizationRequest(TypedDict, total=False):
creator_uid: str


class BotCreateOrganizationRequest(TypedDict):
"""Represents bot organization creation payloads."""

guild_id: Required[int]
name: Required[str]


class UpdateOrganizationRequest(TypedDict, total=False):
"""Represents organization update payloads."""

Expand All @@ -144,6 +154,7 @@ class OrganizationResponse(TypedDict, total=False):

oid: str
name: str
guild_id: int
date_created: str
date_modified: str

Expand Down Expand Up @@ -356,11 +367,21 @@ async def get_organization(self, organization_id: str) -> OrganizationResponse:
payload = await self._request("GET", f"/organizations/{organization_id}")
return cast("OrganizationResponse", _typed_dict(payload))

async def get_bot_organization_by_guild_id(self, guild_id: int) -> OrganizationResponse:
"""Call `GET /organizations/guilds/{guild_id}`."""
payload = await self._request("GET", f"/organizations/guilds/{guild_id}")
return cast("OrganizationResponse", _typed_dict(payload))

async def create_organization(self, data: CreateOrganizationRequest) -> OrganizationResponse:
"""Call `POST /organizations`."""
payload = await self._request("POST", "/organizations", json_body=data, expected_statuses={HTTP_STATUS_CREATED})
return cast("OrganizationResponse", _typed_dict(payload))

async def create_bot_organization(self, data: BotCreateOrganizationRequest) -> OrganizationResponse:
"""Call `POST /organizations` for a bot-managed guild organization."""
payload = await self._request("POST", "/organizations", json_body=data, expected_statuses={HTTP_STATUS_CREATED})
return cast("OrganizationResponse", _typed_dict(payload))

async def update_organization(self, organization_id: str, data: UpdateOrganizationRequest) -> OrganizationResponse:
"""Call `PUT /organizations/{oid}`."""
payload = await self._request("PUT", f"/organizations/{organization_id}", json_body=data)
Expand Down
2 changes: 2 additions & 0 deletions capy_discord/exts/event/_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
class EventSchema(BaseModel):
"""Pydantic model defining the Event schema and validation rules."""

event_id: str | None = Field(default=None, json_schema_extra={"ui_hidden": True})

event_name: str = Field(title="Event Name", description="Name of the event", max_length=100)
event_date: date = Field(
title="Event Date",
Expand Down
148 changes: 93 additions & 55 deletions capy_discord/exts/event/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
BackendAPIError,
CreateEventRequest,
EventResponse,
HTTP_STATUS_NOT_FOUND,
UpdateEventRequest,
get_database_pool,
)
Expand Down Expand Up @@ -141,6 +142,7 @@ def __init__(self, bot: commands.Bot) -> None:
self.log.info("Event cog initialized")
# Track announcement messages: guild_id -> {event_name: message_id}
self.event_announcements: dict[int, dict[str, int]] = {}
self._guild_org_ids: dict[int, str] = {}

@app_commands.command(name="event", description="Manage events")
@app_commands.describe(action="The action to perform with events")
Expand Down Expand Up @@ -199,15 +201,19 @@ async def handle_delete_action(self, interaction: discord.Interaction) -> None:
async def handle_list_action(self, interaction: discord.Interaction) -> None:
"""Handle listing all events."""
guild_id = interaction.guild_id
guild = interaction.guild
if not guild_id:
embed = error_embed("No Server", "Events must be listed in a server.")
await interaction.response.send_message(embed=embed, ephemeral=True)
return
if not guild:
embed = error_embed("No Server", "Could not determine the server.")
await interaction.response.send_message(embed=embed, ephemeral=True)
return

await interaction.response.defer(ephemeral=True)

# Fetch events from backend using guild_id as org_id
events = await self._fetch_backend_events(str(guild_id))
events = await self._fetch_backend_events(await self._resolve_org_id(guild))

if not events:
embed = error_embed("No Events", "No events found in this server.")
Expand Down Expand Up @@ -264,15 +270,13 @@ async def handle_announce_action(self, interaction: discord.Interaction) -> None

async def handle_myevents_action(self, interaction: discord.Interaction) -> None:
"""Handle showing events the user has registered for via RSVP."""
guild_id = interaction.guild_id
guild = interaction.guild
if not guild_id or not guild:
if not interaction.guild_id or not guild:
embed = error_embed("No Server", "Events must be viewed in a server.")
await interaction.response.send_message(embed=embed, ephemeral=True)
return

# Fetch events from backend using guild_id as org_id
events = await self._fetch_backend_events(str(guild_id))
events = await self._fetch_backend_events(await self._resolve_org_id(guild))

if not events:
embed = error_embed("No Events", "No events found in this server.")
Expand Down Expand Up @@ -337,15 +341,19 @@ async def _get_events_for_dropdown(
callback: Async callback to handle the selected event.
"""
guild_id = interaction.guild_id
guild = interaction.guild
if not guild_id:
embed = error_embed("No Server", f"Events must be {action_name}ed in a server.")
await interaction.response.send_message(embed=embed, ephemeral=True)
return
if not guild:
embed = error_embed("No Server", f"Could not determine the server to {action_name} events.")
await interaction.response.send_message(embed=embed, ephemeral=True)
return

await interaction.response.defer(ephemeral=True)

# Fetch events from backend using guild_id as org_id
events = await self._fetch_backend_events(str(guild_id))
events = await self._fetch_backend_events(await self._resolve_org_id(guild))

if not events:
embed = error_embed("No Events", f"No events found in this server to {action_name}.")
Expand Down Expand Up @@ -398,6 +406,34 @@ def _format_when_where(self, event: EventSchema) -> str:
time_str = self._format_event_time_est(event)
return f"**When:** {time_str}\n**Where:** {event.location or 'TBD'}"

async def _resolve_org_id(self, guild: discord.Guild) -> str:
"""Resolve and cache the backend org id for the current guild."""
cached_org_id = self._guild_org_ids.get(guild.id)
if cached_org_id:
return cached_org_id

client = get_database_pool()
try:
organization = await client.get_bot_organization_by_guild_id(guild.id)
except BackendAPIError as exc:
if exc.status_code != HTTP_STATUS_NOT_FOUND:
raise

organization = await client.create_bot_organization(
{
"guild_id": guild.id,
"name": guild.name or f"Guild {guild.id}",
}
)

organization_id = str(organization.get("oid", "")).strip()
if not organization_id:
msg = "Backend did not return an organization id"
raise BackendAPIError(msg, status_code=0)

self._guild_org_ids[guild.id] = organization_id
return organization_id

def _apply_event_fields(self, embed: discord.Embed, event: EventSchema) -> None:
"""Append event detail fields to an embed."""
embed.add_field(name="Event", value=event.event_name, inline=False)
Expand Down Expand Up @@ -464,6 +500,7 @@ async def _is_user_registered(
async def _on_edit_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None:
"""Handle event selection for editing."""
initial_data = {
"event_id": selected_event.event_id,
"event_name": selected_event.event_name,
"event_date": selected_event.event_date.strftime("%m-%d-%Y"),
"event_time": selected_event.event_time.strftime("%H:%M"),
Expand Down Expand Up @@ -590,8 +627,9 @@ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed:
async def _handle_event_submit(self, interaction: discord.Interaction, event: EventSchema) -> None:
"""Process the valid event submission."""
guild_id = interaction.guild_id
guild = interaction.guild

if not guild_id:
if not guild_id or not guild:
embed = error_embed("No Server", "Events must be created in a server.")
await self._respond_from_modal(interaction, embed)
return
Expand All @@ -604,14 +642,12 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev
event_datetime = event_datetime.replace(tzinfo=est)
event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat()

# Encode event name in description
encoded_description = self._encode_event_description(event.event_name, event.description)

# Create event in backend
client = get_database_pool()
request_data: CreateEventRequest = {
"org_id": str(guild_id),
"description": encoded_description,
"org_id": await self._resolve_org_id(guild),
"title": event.event_name,
"description": event.description,
"event_time": event_time_iso,
"location": event.location,
}
Expand Down Expand Up @@ -660,8 +696,9 @@ async def _handle_event_update(
) -> None:
"""Process the event update submission."""
guild_id = interaction.guild_id
guild = interaction.guild

if not guild_id:
if not guild_id or not guild:
embed = error_embed("No Server", "Events must be updated in a server.")
await self._respond_from_modal(interaction, embed)
return
Expand All @@ -674,30 +711,18 @@ async def _handle_event_update(
event_datetime = event_datetime.replace(tzinfo=est)
event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat()

# Encode event name in description
encoded_description = self._encode_event_description(updated_event.event_name, updated_event.description)

# For now, we need to find the event ID from the backend
# We'll search for events matching the original event name
client = get_database_pool()
backend_events = await client.list_events_by_organization(str(guild_id))

event_id = None
for be in backend_events:
desc = be.get("description", "")
name, _ = self._decode_event_description(desc)
if name == original_event.event_name:
event_id = be.get("eid")
break
event_id = original_event.event_id

if not event_id:
embed = error_embed("Event Not Found", "Could not find the event to update.")
await self._respond_from_modal(interaction, embed)
return

# Update event in backend
client = get_database_pool()
request_data: UpdateEventRequest = {
"description": encoded_description,
"title": updated_event.event_name,
"description": updated_event.description,
"event_time": event_time_iso,
"location": updated_event.location,
}
Expand Down Expand Up @@ -753,20 +778,15 @@ async def _on_delete_select(self, interaction: discord.Interaction, selected_eve
await interaction.followup.send(embed=success, ephemeral=True)
return

client = get_database_pool()
backend_events = await client.list_events_by_organization(str(guild_id))

event_id = None
for be in backend_events:
desc = be.get("description", "")
name, _ = self._decode_event_description(desc)
if name == selected_event.event_name:
event_id = be.get("eid")
break
event_id = selected_event.event_id
if not event_id:
error = error_embed("Event Not Found", "Could not find the event to delete.")
await interaction.followup.send(embed=error, ephemeral=True)
return

if event_id:
await client.delete_event(event_id)
self.log.info("Deleted event '%s' from guild %s", selected_event.event_name, guild_id)
client = get_database_pool()
await client.delete_event(event_id)
self.log.info("Deleted event '%s' from guild %s", selected_event.event_name, guild_id)

success = success_embed("Event Deleted", "The event has been deleted successfully!")
await interaction.followup.send(embed=success, ephemeral=True)
Expand All @@ -783,12 +803,31 @@ async def _on_delete_select(self, interaction: discord.Interaction, selected_eve

async def _fetch_backend_events(self, org_id: str) -> list[EventSchema]:
"""Fetch events from the backend for the given organization."""
client = get_database_pool()
try:
client = get_database_pool()
backend_events = await client.list_events_by_organization(org_id)
except BackendAPIError:
self.log.exception("Failed to fetch events from backend")
return []
except BackendAPIError as exc:
if exc.status_code == HTTP_STATUS_NOT_FOUND:
# Some bot API deployments expose /organizations/{oid}/events but not /events/org/{oid}.
try:
backend_events = await client.list_organization_events(org_id)
except BackendAPIError as fallback_exc:
if fallback_exc.status_code == HTTP_STATUS_NOT_FOUND:
# Last-resort fallback for bot APIs that only expose GET /events.
try:
backend_events = await client.list_events(limit=100, offset=0)
except BackendAPIError:
self.log.exception("Failed to fetch events from backend")
return []
else:
self.log.exception("Failed to fetch events from backend")
return []
else:
self.log.exception("Failed to fetch events from backend")
return []

# If org_id is available in payload, keep this guild scoped; otherwise keep fallback results.
backend_events = [event for event in backend_events if str(event.get("org_id", "")).strip() in {"", org_id}]

events = []
for backend_event in backend_events:
Expand All @@ -802,11 +841,6 @@ async def _fetch_backend_events(self, org_id: str) -> list[EventSchema]:
events.sort(key=self._event_datetime)
return events

@staticmethod
def _encode_event_description(event_name: str, description: str) -> str:
"""Encode event_name into the description since backend doesn't have this field."""
return f"[capy_event_name]{event_name}\n{description}"

@staticmethod
def _decode_event_description(encoded: str) -> tuple[str, str]:
"""Decode event_name and description from encoded string."""
Expand All @@ -821,9 +855,12 @@ def _decode_event_description(encoded: str) -> tuple[str, str]:

def _from_backend_event(self, backend_event: EventResponse) -> EventSchema:
"""Convert a backend event response to EventSchema."""
# Decode the event name from description
# Prefer first-class title field; keep legacy encoded fallback for older rows.
description_text = backend_event.get("description", "")
event_name, description = self._decode_event_description(description_text)
event_name = backend_event.get("title", "")
description = description_text
if not event_name:
event_name, description = self._decode_event_description(description_text)

# Parse ISO event_time to date and time
event_time_str = backend_event.get("event_time", "")
Expand All @@ -843,6 +880,7 @@ def _from_backend_event(self, backend_event: EventResponse) -> EventSchema:
event_time = datetime.now(ZoneInfo("America/New_York")).time()

return EventSchema(
event_id=backend_event.get("eid"),
event_name=event_name,
event_date=event_date,
event_time=event_time,
Expand Down
Loading
Loading