feat: add /trip slash commands + add trips & itinerary system
New /trips router with full CRUD, attendee management, and nested events. Events validate date is within parent trip range and inherit trip location when not explicitly set. Leaving a trip cascades removal from all its events. New TripCommands cog with /trip create, list, view, delete, join, leave and /trip event add, remove, join, leave. Event autocomplete is scoped to the selected trip. Enforces must-be-on-trip rule for event joins with a clear error message.
This commit is contained in:
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||
from app.config import settings
|
||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ app.include_router(calls.router, dependencies=[Depends(require_service_or_fi
|
||||
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
|
||||
|
||||
@@ -134,3 +134,29 @@ class AlertEvent(BaseModel):
|
||||
transcript_snippet: Optional[str] = None
|
||||
triggered_at: Optional[datetime] = None
|
||||
acknowledged: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trips
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TripCreate(BaseModel):
|
||||
name: str
|
||||
location: str
|
||||
maps_link: Optional[str] = None
|
||||
start_date: str # YYYY-MM-DD
|
||||
end_date: str # YYYY-MM-DD
|
||||
|
||||
|
||||
class TripEventCreate(BaseModel):
|
||||
title: str
|
||||
date: str # YYYY-MM-DD, must fall within parent trip range
|
||||
time: Optional[str] = None # HH:MM (24h)
|
||||
location: Optional[str] = None # inherits trip location if None
|
||||
maps_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AttendeeAction(BaseModel):
|
||||
discord_user_id: str
|
||||
discord_username: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trips():
|
||||
return await fstore.collection_list("trips")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_trip(body: TripCreate):
|
||||
if body.end_date < body.start_date:
|
||||
raise HTTPException(400, "end_date must be on or after start_date.")
|
||||
trip_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"trip_id": trip_id,
|
||||
"name": body.name,
|
||||
"location": body.location,
|
||||
"maps_link": body.maps_link,
|
||||
"start_date": body.start_date,
|
||||
"end_date": body.end_date,
|
||||
"attendees": {}, # {discord_user_id: discord_username}
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/{trip_id}")
|
||||
async def get_trip(trip_id: str):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
|
||||
return {**trip, "events": events}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}")
|
||||
async def delete_trip(trip_id: str):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
await fstore.doc_delete("trip_events", e["event_id"])
|
||||
await fstore.doc_delete("trips", trip_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/join")
|
||||
async def join_trip(trip_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/leave")
|
||||
async def leave_trip(trip_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
# cascade: remove from all events in this trip
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
event_attendees = e.get("attendees", {})
|
||||
if body.discord_user_id in event_attendees:
|
||||
event_attendees.pop(body.discord_user_id)
|
||||
await fstore.doc_update("trip_events", e["event_id"], {"attendees": event_attendees})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events")
|
||||
async def create_event(trip_id: str, body: TripEventCreate):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if not (trip["start_date"] <= body.date <= trip["end_date"]):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Event date {body.date} is outside the trip range "
|
||||
f"{trip['start_date']} – {trip['end_date']}.",
|
||||
)
|
||||
event_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"event_id": event_id,
|
||||
"trip_id": trip_id,
|
||||
"title": body.title,
|
||||
"date": body.date,
|
||||
"time": body.time,
|
||||
"location": body.location if body.location is not None else trip["location"],
|
||||
"location_inherited": body.location is None,
|
||||
"maps_link": body.maps_link,
|
||||
"notes": body.notes,
|
||||
"attendees": {},
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trip_events", event_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/events/{event_id}")
|
||||
async def delete_event(trip_id: str, event_id: str):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
await fstore.doc_delete("trip_events", event_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/join")
|
||||
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if body.discord_user_id not in trip.get("attendees", {}):
|
||||
raise HTTPException(403, "You must join the trip before joining an event.")
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/leave")
|
||||
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True}
|
||||
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
|
||||
|
||||
async def setup_hook(self):
|
||||
await self.load_extension("app.commands.radio")
|
||||
await self.load_extension("app.commands.trips")
|
||||
|
||||
if settings.dev_guild_id:
|
||||
guild = discord.Object(id=settings.dev_guild_id)
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
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", {}))
|
||||
embed.add_field(
|
||||
name=f"{t['name']} [{status}]",
|
||||
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"],
|
||||
description="\n".join(desc_lines),
|
||||
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)
|
||||
|
||||
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:
|
||||
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("time") or ""):
|
||||
time_str = _fmt_time(e.get("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_att = list(e.get("attendees", {}).values())
|
||||
if event_att:
|
||||
line += f"\n\u3000\u3000{', '.join(event_att)}"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
embed.add_field(name=f"— {day_label} —", value="\n".join(lines), inline=False)
|
||||
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)",
|
||||
time="Time of the event (e.g. 14:00 or 2: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,
|
||||
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_time = _parse_time(time) if time else None
|
||||
if time and parsed_time is None:
|
||||
await interaction.followup.send(
|
||||
"Couldn't parse that time. Try `14:00` or `2:00 PM`."
|
||||
)
|
||||
return
|
||||
|
||||
event = await c2.create_trip_event(trip, {
|
||||
"title": title,
|
||||
"date": parsed_date.strftime("%Y-%m-%d"),
|
||||
"time": parsed_time,
|
||||
"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_time)}" if parsed_time 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))
|
||||
@@ -68,5 +68,137 @@ class C2Client:
|
||||
return node
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trips
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_trips(self) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/trips", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_trips failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_trip(self, trip_id: str) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_trip failed: {e}")
|
||||
return None
|
||||
|
||||
async def create_trip(self, payload: dict) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.base}/trips", json=payload, headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 create_trip failed: {e}")
|
||||
return None
|
||||
|
||||
async def delete_trip(self, trip_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.delete(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 delete_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/join",
|
||||
json={"discord_user_id": user_id, "discord_username": username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 join_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def leave_trip(self, trip_id: str, user_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/leave",
|
||||
json={"discord_user_id": user_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 leave_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def create_trip_event(self, trip_id: str, payload: dict) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 create_trip_event failed: {e}")
|
||||
return None
|
||||
|
||||
async def delete_trip_event(self, trip_id: str, event_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.delete(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 delete_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
async def join_trip_event(
|
||||
self, trip_id: str, event_id: str, user_id: str, username: str
|
||||
) -> bool | str:
|
||||
"""Returns True on success, 'not_on_trip' on 403, False on other errors."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}/join",
|
||||
json={"discord_user_id": user_id, "discord_username": username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if r.status_code == 403:
|
||||
return "not_on_trip"
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 join_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
async def leave_trip_event(self, trip_id: str, event_id: str, user_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}/leave",
|
||||
json={"discord_user_id": user_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 leave_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
c2 = C2Client()
|
||||
|
||||
Reference in New Issue
Block a user