diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index e286db1..2f5c82f 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -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) diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 49d8079..2db9d5f 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -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 diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py new file mode 100644 index 0000000..8257bd6 --- /dev/null +++ b/drb-c2-core/app/routers/trips.py @@ -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} diff --git a/drb-server-discord-bot/app/bot.py b/drb-server-discord-bot/app/bot.py index 3ae915c..4679d81 100644 --- a/drb-server-discord-bot/app/bot.py +++ b/drb-server-discord-bot/app/bot.py @@ -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) diff --git a/drb-server-discord-bot/app/commands/trips.py b/drb-server-discord-bot/app/commands/trips.py new file mode 100644 index 0000000..6b24782 --- /dev/null +++ b/drb-server-discord-bot/app/commands/trips.py @@ -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)) diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py index 558c0dd..1e8eea0 100644 --- a/drb-server-discord-bot/app/internal/c2_client.py +++ b/drb-server-discord-bot/app/internal/c2_client.py @@ -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()