Compare commits

..

2 Commits

Author SHA1 Message Date
Logan fb096d582d 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.
2026-06-20 23:25:08 -04:00
Logan a4962d7b0e map fixes 2026-06-20 23:19:41 -04:00
7 changed files with 732 additions and 3 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token 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 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(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.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(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(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
+26
View File
@@ -134,3 +134,29 @@ class AlertEvent(BaseModel):
transcript_snippet: Optional[str] = None transcript_snippet: Optional[str] = None
triggered_at: Optional[datetime] = None triggered_at: Optional[datetime] = None
acknowledged: bool = False 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
+150
View File
@@ -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}
+2 -2
View File
@@ -61,11 +61,11 @@ export default function MapPage() {
</div> </div>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm"> <div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
Loading map Loading map
</div> </div>
) : ( ) : (
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]"> <div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
<MapView <MapView
nodes={nodes} nodes={nodes}
activeCalls={activeCalls} activeCalls={activeCalls}
+1
View File
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
async def setup_hook(self): async def setup_hook(self):
await self.load_extension("app.commands.radio") await self.load_extension("app.commands.radio")
await self.load_extension("app.commands.trips")
if settings.dev_guild_id: if settings.dev_guild_id:
guild = discord.Object(id=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 node
return None 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() c2 = C2Client()