import discord from discord import app_commands from discord.ext import commands from datetime import datetime, date, timedelta from typing import Optional from app.internal.c2_client import c2 from app.internal.logger import logger # --------------------------------------------------------------------------- # Date / time helpers # --------------------------------------------------------------------------- def _parse_date(s: str) -> Optional[date]: for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y"): try: return datetime.strptime(s.strip(), fmt).date() except ValueError: continue return None def _parse_time(s: str) -> Optional[str]: """Normalize to HH:MM (24h). Returns None if unparseable.""" for fmt in ("%H:%M", "%I:%M %p", "%I:%M%p", "%I %p"): try: return datetime.strptime(s.strip().upper(), fmt).strftime("%H:%M") except ValueError: continue return None def _fmt_date(iso: str) -> str: try: return datetime.strptime(iso, "%Y-%m-%d").strftime("%b %-d, %Y") except Exception: return iso def _fmt_time(t: Optional[str]) -> str: if not t: return "" try: return datetime.strptime(t, "%H:%M").strftime("%-I:%M %p") except Exception: return t def _date_range(start_iso: str, end_iso: str): """Yield ISO date strings from start to end inclusive.""" try: current = datetime.strptime(start_iso, "%Y-%m-%d").date() end = datetime.strptime(end_iso, "%Y-%m-%d").date() while current <= end: yield current.strftime("%Y-%m-%d") current += timedelta(days=1) except Exception: return # --------------------------------------------------------------------------- # Cog # --------------------------------------------------------------------------- class TripCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot trip_group = app_commands.Group(name="trip", description="Manage trips and itineraries.") event_group = app_commands.Group( name="event", description="Manage events within a trip.", parent=trip_group ) # ------------------------------------------------------------------ # Autocomplete # ------------------------------------------------------------------ async def trip_autocomplete( self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: trips = await c2.get_trips() return [ app_commands.Choice(name=t["name"], value=t["trip_id"]) for t in trips if current.lower() in t["name"].lower() ][:25] async def event_autocomplete( self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: trip_id = interaction.namespace.trip if not trip_id: return [] trip = await c2.get_trip(trip_id) if not trip: return [] return [ app_commands.Choice(name=e["title"], value=e["event_id"]) for e in trip.get("events", []) if current.lower() in e["title"].lower() ][:25] # ------------------------------------------------------------------ # /trip create # ------------------------------------------------------------------ @trip_group.command(name="create", description="Create a new trip.") @app_commands.describe( name="Trip name", location="Primary destination or location", start_date="Start date (YYYY-MM-DD or MM/DD/YYYY)", end_date="End date (YYYY-MM-DD or MM/DD/YYYY)", maps_link="Optional Google Maps link for the destination", ) async def trip_create( self, interaction: discord.Interaction, name: str, location: str, start_date: str, end_date: str, maps_link: Optional[str] = None, ): await interaction.response.defer(ephemeral=True) start = _parse_date(start_date) end = _parse_date(end_date) if not start or not end: await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.") return if end < start: await interaction.followup.send("End date must be on or after start date.") return trip = await c2.create_trip({ "name": name, "location": location, "maps_link": maps_link, "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d"), }) if not trip: await interaction.followup.send("Failed to create trip.") return embed = discord.Embed(title=f"Trip Created: {name}", color=0x5865f2) embed.add_field(name="Location", value=location, inline=True) embed.add_field( name="Dates", value=f"{_fmt_date(start.strftime('%Y-%m-%d'))} — {_fmt_date(end.strftime('%Y-%m-%d'))}", inline=True, ) if maps_link: embed.add_field(name="Maps", value=f"[Open]({maps_link})", inline=True) embed.set_footer(text="Use /trip join to RSVP • /trip event add to build the itinerary") await interaction.followup.send(embed=embed) # ------------------------------------------------------------------ # /trip list # ------------------------------------------------------------------ @trip_group.command(name="list", description="List all trips.") async def trip_list(self, interaction: discord.Interaction): await interaction.response.defer() trips = await c2.get_trips() if not trips: await interaction.followup.send("No trips found.") return today = date.today().strftime("%Y-%m-%d") trips.sort(key=lambda t: t.get("start_date", "")) embed = discord.Embed(title="Trips", color=0x2b2d31) for t in trips[:10]: upcoming = t.get("start_date", "") >= today status = "Upcoming" if upcoming else "Past" dates = ( f"{_fmt_date(t.get('start_date', ''))} — " f"{_fmt_date(t.get('end_date', ''))}" ) attendee_count = len(t.get("attendees", {})) field_name = f"{t['name']} [{status}]"[:256] embed.add_field( name=field_name, value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going", inline=False, ) await interaction.followup.send(embed=embed) # ------------------------------------------------------------------ # /trip view # ------------------------------------------------------------------ @trip_group.command(name="view", description="View the full itinerary for a trip.") @app_commands.describe(trip="The trip to view.") @app_commands.autocomplete(trip=trip_autocomplete) async def trip_view(self, interaction: discord.Interaction, trip: str): await interaction.response.defer() data = await c2.get_trip(trip) if not data: await interaction.followup.send("Trip not found.") return attendee_names = list(data.get("attendees", {}).values()) desc_lines = [ f"{_fmt_date(data['start_date'])} — {_fmt_date(data['end_date'])} • {data['location']}", ] if data.get("maps_link"): desc_lines.append(f"[View on Maps]({data['maps_link']})") desc_lines.append( f"Going: {', '.join(attendee_names)}" if attendee_names else "No attendees yet" ) embed = discord.Embed( title=data["name"][:256], description="\n".join(desc_lines)[:4096], color=0x5865f2, ) # Group events by date events_by_date: dict[str, list] = {} for e in data.get("events", []): events_by_date.setdefault(e["date"], []).append(e) # Track total embed chars (Discord limit: 6000) embed_chars = len(embed.title or "") + len(embed.description or "") field_count = 0 for day_iso in _date_range(data["start_date"], data["end_date"]): day_events = events_by_date.get(day_iso) if not day_events: continue if field_count >= 24 or embed_chars >= 5800: embed.add_field(name="...", value="More events not shown.", inline=False) break day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d") lines = [] for e in sorted(day_events, key=lambda x: x.get("start_time") or ""): time_str = _fmt_time(e.get("start_time")) line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}" loc = e.get("location") if loc and not e.get("location_inherited"): line += f"\n\u3000\u3000{loc}" if e.get("maps_link"): line += f" ([Maps]({e['maps_link']}))" if e.get("notes"): line += f"\n\u3000\u3000_{e['notes']}_" event_tags = e.get("tags") or [] if event_tags: line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`" event_att = list(e.get("attendees", {}).values()) if event_att: line += f"\n\u3000\u3000{', '.join(event_att)}" lines.append(line) field_name = f"— {day_label} —" field_value = "\n".join(lines) if len(field_value) > 1024: field_value = field_value[:1021] + "…" embed.add_field(name=field_name, value=field_value, inline=False) embed_chars += len(field_name) + len(field_value) field_count += 1 if not events_by_date: embed.add_field( name="No events yet", value="Use `/trip event add` to build the itinerary.", inline=False, ) await interaction.followup.send(embed=embed) # ------------------------------------------------------------------ # /trip delete # ------------------------------------------------------------------ @trip_group.command(name="delete", description="Delete a trip and all its events.") @app_commands.describe(trip="The trip to delete.") @app_commands.autocomplete(trip=trip_autocomplete) async def trip_delete(self, interaction: discord.Interaction, trip: str): await interaction.response.defer(ephemeral=True) ok = await c2.delete_trip(trip) if ok: await interaction.followup.send("Trip deleted.") else: await interaction.followup.send("Trip not found or failed to delete.") # ------------------------------------------------------------------ # /trip join / /trip leave # ------------------------------------------------------------------ @trip_group.command(name="join", description="RSVP to a trip.") @app_commands.describe(trip="The trip to join.") @app_commands.autocomplete(trip=trip_autocomplete) async def trip_join(self, interaction: discord.Interaction, trip: str): await interaction.response.defer(ephemeral=True) ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) if ok: await interaction.followup.send("You're on the trip!") else: await interaction.followup.send("Failed to join trip.") @trip_group.command(name="leave", description="Remove yourself from a trip.") @app_commands.describe(trip="The trip to leave.") @app_commands.autocomplete(trip=trip_autocomplete) async def trip_leave(self, interaction: discord.Interaction, trip: str): await interaction.response.defer(ephemeral=True) ok = await c2.leave_trip(trip, str(interaction.user.id)) if ok: await interaction.followup.send("You've been removed from the trip.") else: await interaction.followup.send("Failed to leave trip.") # ------------------------------------------------------------------ # /trip event add # ------------------------------------------------------------------ @event_group.command(name="add", description="Add an event to a trip's itinerary.") @app_commands.describe( trip="The trip to add this event to.", title="Event title", date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)", start_time="Start time (e.g. 14:00 or 2:00 PM) — optional", end_time="End time (e.g. 16:00 or 4:00 PM) — optional", location="Location override (optional, inherits trip location if omitted)", maps_link="Google Maps link for this event (optional)", notes="Any additional notes (optional)", ) @app_commands.autocomplete(trip=trip_autocomplete) async def event_add( self, interaction: discord.Interaction, trip: str, title: str, date: str, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, maps_link: Optional[str] = None, notes: Optional[str] = None, ): await interaction.response.defer(ephemeral=True) parsed_date = _parse_date(date) if not parsed_date: await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.") return parsed_start = _parse_time(start_time) if start_time else None parsed_end = _parse_time(end_time) if end_time else None if start_time and parsed_start is None: await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.") return if end_time and parsed_end is None: await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.") return event = await c2.create_trip_event(trip, { "title": title, "date": parsed_date.strftime("%Y-%m-%d"), "start_time": parsed_start, "end_time": parsed_end, "location": location, "maps_link": maps_link, "notes": notes, }) if not event: await interaction.followup.send( "Failed to create event. Make sure the date falls within the trip range." ) return time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else "" await interaction.followup.send( f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}." ) # ------------------------------------------------------------------ # /trip event remove # ------------------------------------------------------------------ @event_group.command(name="remove", description="Remove an event from a trip.") @app_commands.describe(trip="The trip.", event="The event to remove.") @app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete) async def event_remove(self, interaction: discord.Interaction, trip: str, event: str): await interaction.response.defer(ephemeral=True) ok = await c2.delete_trip_event(trip, event) if ok: await interaction.followup.send("Event removed.") else: await interaction.followup.send("Event not found or failed to remove.") # ------------------------------------------------------------------ # /trip event join / /trip event leave # ------------------------------------------------------------------ @event_group.command(name="join", description="Join an event (you must be on the trip first).") @app_commands.describe(trip="The trip.", event="The event to join.") @app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete) async def event_join(self, interaction: discord.Interaction, trip: str, event: str): await interaction.response.defer(ephemeral=True) result = await c2.join_trip_event( trip, event, str(interaction.user.id), interaction.user.display_name ) if result is True: await interaction.followup.send("You're in for this event!") elif result == "not_on_trip": await interaction.followup.send( "You need to join the trip first — use `/trip join`." ) else: await interaction.followup.send("Failed to join event.") @event_group.command(name="leave", description="Leave an event.") @app_commands.describe(trip="The trip.", event="The event to leave.") @app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete) async def event_leave(self, interaction: discord.Interaction, trip: str, event: str): await interaction.response.defer(ephemeral=True) ok = await c2.leave_trip_event(trip, event, str(interaction.user.id)) if ok: await interaction.followup.send("You've been removed from the event.") else: await interaction.followup.send("Failed to leave event.") async def setup(bot: commands.Bot): await bot.add_cog(TripCommands(bot))