Files
server-26/drb-server-discord-bot/app/commands/trips.py
T
2026-06-21 20:00:48 -04:00

516 lines
21 KiB
Python

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
# ---------------------------------------------------------------------------
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
if trip.get("visibility", "public") == "public":
return True
if discord_user_id in trip.get("attendees", {}):
return True
if discord_user_id in trip.get("invited_discord_ids", []):
return True
return False
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()
user_id = str(interaction.user.id)
return [
app_commands.Choice(name=t["name"], value=t["trip_id"])
for t in trips
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
][: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", ""))
user_id = str(interaction.user.id)
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
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
if not _user_can_see_trip(data, str(interaction.user.id)):
await interaction.followup.send("This trip is private.", ephemeral=True)
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)
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
if result is True:
await interaction.followup.send("You're on the trip!")
elif result == "private":
await interaction.followup.send("This trip is private — you need an invite to join.")
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.")
# ------------------------------------------------------------------
# /trip invite
# ------------------------------------------------------------------
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
@app_commands.describe(trip="The trip.", user="The user to invite.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
await interaction.response.defer(ephemeral=True)
ok = await c2.invite_to_trip(trip, str(user.id))
if ok:
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
else:
await interaction.followup.send("Failed to send invite.")
# ------------------------------------------------------------------
# /trip privacy
# ------------------------------------------------------------------
@trip_group.command(name="privacy", description="Set a trip to public or private.")
@app_commands.describe(trip="The trip.", visibility="public or private")
@app_commands.autocomplete(trip=trip_autocomplete)
@app_commands.choices(visibility=[
app_commands.Choice(name="Public — anyone can see and join", value="public"),
app_commands.Choice(name="Private — invite only", value="private"),
])
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.set_trip_visibility(trip, visibility)
if ok:
await interaction.followup.send(f"Trip is now **{visibility}**.")
else:
await interaction.followup.send("Failed to update trip privacy.")
# ------------------------------------------------------------------
# /link
# ------------------------------------------------------------------
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
async def link_account(self, interaction: discord.Interaction, code: str):
await interaction.response.defer(ephemeral=True)
result = await c2.link_discord_account(
code.upper().strip(),
str(interaction.user.id),
interaction.user.display_name,
)
if "error" in result:
msgs = {
"invalid_code": "Invalid code. Generate a new one from the web app.",
"expired": "Code has expired. Generate a new one from the web app.",
"already_linked": "This Discord account is already linked to a different web account.",
"failed": "Something went wrong. Try again.",
}
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
else:
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
async def setup(bot: commands.Bot):
await bot.add_cog(TripCommands(bot))