add trips permissions

This commit is contained in:
Logan
2026-06-21 20:00:48 -04:00
parent 981f03ac06
commit 6ae4d398f8
9 changed files with 425 additions and 10 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, trips, places from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(places.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)
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
@app.get("/health") @app.get("/health")
+2
View File
@@ -148,6 +148,8 @@ class TripCreate(BaseModel):
end_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel): class TripEventCreate(BaseModel):
+128
View File
@@ -0,0 +1,128 @@
import random
import string
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
_CODE_TTL_MINUTES = 15
def _gen_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
# ---------------------------------------------------------------------------
# Web: generate a short-lived linking code
# ---------------------------------------------------------------------------
@router.post("/link/generate")
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
"""Authenticated Firebase user generates a code to paste into Discord /link."""
firebase_uid = decoded["uid"]
# Check if already linked
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
if existing and existing.get("discord_user_id"):
return {
"already_linked": True,
"discord_user_id": existing["discord_user_id"],
}
code = _gen_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
await fstore.doc_set("link_codes", code, {
"firebase_uid": firebase_uid,
"expires_at": expires_at,
}, merge=False)
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
# ---------------------------------------------------------------------------
# Discord bot: resolve a code and store the link
# ---------------------------------------------------------------------------
class LinkResolveBody(BaseModel):
code: str
discord_user_id: str
discord_username: str = ""
@router.post("/link")
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
"""Discord bot resolves a linking code and permanently links the accounts."""
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
if not doc:
raise HTTPException(404, "Invalid or expired code.")
expires_at = datetime.fromisoformat(doc["expires_at"])
if datetime.now(timezone.utc) > expires_at:
await fstore.doc_delete("link_codes", body.code)
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
firebase_uid = doc["firebase_uid"]
# Check if this Discord account is already linked to a different Firebase UID
existing = await fstore.doc_get("discord_links", body.discord_user_id)
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
raise HTTPException(409, "This Discord account is already linked to a different account.")
now = datetime.now(timezone.utc).isoformat()
# Store both directions
await fstore.doc_set("discord_links", body.discord_user_id, {
"firebase_uid": firebase_uid,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
await fstore.doc_set("firebase_discord_links", firebase_uid, {
"discord_user_id": body.discord_user_id,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
# Clean up the code
await fstore.doc_delete("link_codes", body.code)
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
return {"ok": True, "firebase_uid": firebase_uid}
# ---------------------------------------------------------------------------
# Web: check current link status
# ---------------------------------------------------------------------------
@router.get("/link/status")
async def link_status(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if link and link.get("discord_user_id"):
return {
"linked": True,
"discord_user_id": link["discord_user_id"],
"discord_username": link.get("discord_username", ""),
"linked_at": link.get("linked_at"),
}
return {"linked": False}
# ---------------------------------------------------------------------------
# Web: unlink
# ---------------------------------------------------------------------------
@router.delete("/link")
async def unlink(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if not link or not link.get("discord_user_id"):
raise HTTPException(404, "No linked Discord account.")
discord_user_id = link["discord_user_id"]
await fstore.doc_delete("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True}
+78 -4
View File
@@ -18,6 +18,32 @@ from app.internal.auth import (
router = APIRouter(prefix="/trips", tags=["trips"]) router = APIRouter(prefix="/trips", tags=["trips"])
# ---------------------------------------------------------------------------
# Access control helpers
# ---------------------------------------------------------------------------
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
"""Return True if the caller may read this trip."""
if is_service:
return True # bot sees all; it filters client-side per-user
if trip.get("visibility", "public") == "public":
return True
if not firebase_uid:
return False
# attendees keyed by discord_id — check linked discord_id
if discord_id:
if discord_id in trip.get("attendees", {}):
return True
if discord_id in trip.get("invited_discord_ids", []):
return True
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# AI assistant — tool definitions # AI assistant — tool definitions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
@router.get("") @router.get("")
async def list_trips(): async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
return await fstore.collection_list("trips") trips = await fstore.collection_list("trips")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
@router.post("") @router.post("")
@@ -209,6 +239,8 @@ async def create_trip(body: TripCreate):
"attendees": {}, # {discord_user_id: discord_username} "attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags, "available_tags": body.available_tags,
"overlap_tags": body.overlap_tags, "overlap_tags": body.overlap_tags,
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
"invited_discord_ids": body.invited_discord_ids,
"created_at": now, "created_at": now,
} }
await fstore.doc_set("trips", trip_id, doc, merge=False) await fstore.doc_set("trips", trip_id, doc, merge=False)
@@ -216,12 +248,17 @@ async def create_trip(body: TripCreate):
@router.get("/{trip_id}") @router.get("/{trip_id}")
async def get_trip(trip_id: str): async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
raise HTTPException(403, "This trip is private.")
events = await fstore.collection_list("trip_events", trip_id=trip_id) events = await fstore.collection_list("trip_events", trip_id=trip_id)
events.sort(key=lambda e: (e["date"], e.get("time") or "")) events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
return {**trip, "events": events} return {**trip, "events": events}
@@ -259,12 +296,49 @@ async def join_trip(
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
if trip.get("visibility", "public") == "private":
invited = trip.get("invited_discord_ids", [])
attendees_existing = trip.get("attendees", {})
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
raise HTTPException(403, "This trip is private. You need an invite to join.")
attendees = trip.get("attendees", {}) attendees = trip.get("attendees", {})
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
await fstore.doc_update("trips", trip_id, {"attendees": attendees}) await fstore.doc_update("trips", trip_id, {"attendees": attendees})
return {"ok": True, "attendees": attendees} return {"ok": True, "attendees": attendees}
@router.put("/{trip_id}/visibility")
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
visibility = body.get("visibility", "public")
if visibility not in ("public", "private"):
raise HTTPException(400, "visibility must be 'public' or 'private'.")
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
return {"visibility": visibility}
@router.post("/{trip_id}/invite/{discord_user_id}")
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.delete("/{trip_id}/invite/{discord_user_id}")
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.post("/{trip_id}/leave") @router.post("/{trip_id}/leave")
async def leave_trip( async def leave_trip(
trip_id: str, trip_id: str,
+69 -1
View File
@@ -908,6 +908,7 @@ export default function TripDetailPage() {
const [editEvent, setEditEvent] = useState<TripEvent | null>(null); const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({}); const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [inviteInput, setInviteInput] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -1024,6 +1025,28 @@ export default function TripDetailPage() {
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev); setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
} }
async function handleToggleVisibility() {
if (!trip) return;
const next = trip.visibility === "private" ? "public" : "private";
await c2api.setTripVisibility(trip.trip_id, next);
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
}
async function handleInvite() {
const discordId = inviteInput.trim();
if (!trip || !discordId) return;
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
await c2api.inviteToTrip(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
setInviteInput("");
}
async function handleRevokeInvite(discordId: string) {
if (!trip) return;
await c2api.revokeInvite(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
}
async function handleToggleOverlap(tag: string) { async function handleToggleOverlap(tag: string) {
if (!trip) return; if (!trip) return;
const current = trip.overlap_tags ?? []; const current = trip.overlap_tags ?? [];
@@ -1074,7 +1097,13 @@ export default function TripDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
{/* Visibility badge */}
{trip.visibility === "private" && (
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
🔒 private
</span>
)}
{isAdmin && ( {isAdmin && (
<> <>
<button <button
@@ -1083,6 +1112,12 @@ export default function TripDetailPage() {
> >
+ Add Event + Add Event
</button> </button>
<button
onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
>
{trip.visibility === "private" ? "Make public" : "Make private"}
</button>
<button <button
onClick={handleDeleteTrip} onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors" className="text-xs text-red-500 hover:text-red-400 transition-colors"
@@ -1131,6 +1166,39 @@ export default function TripDetailPage() {
)} )}
</div> </div>
)} )}
{/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && (
<div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span>
)}
{(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
<span className="font-mono">{discordId}</span>
<button
onClick={() => handleRevokeInvite(discordId)}
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
/>
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
</div>
</div>
)}
</div> </div>
{/* Two-column layout */} {/* Two-column layout */}
+12
View File
@@ -144,6 +144,18 @@ export const c2api = {
request(`/trips/${id}`, { method: "DELETE" }), request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) => updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }), request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
setTripVisibility: (id: string, visibility: "public" | "private") =>
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
inviteToTrip: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
revokeInvite: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
generateLinkCode: () =>
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
getLinkStatus: () =>
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
unlinkDiscord: () =>
request("/auth/link", { method: "DELETE" }),
createTripEvent: (tripId: string, body: object) => createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) => updateTripEvent: (tripId: string, eventId: string, body: object) =>
+2
View File
@@ -135,6 +135,8 @@ export interface TripRecord {
attendees: Record<string, string>; attendees: Record<string, string>;
available_tags: string[]; available_tags: string[];
overlap_tags: string[]; overlap_tags: string[];
visibility: "public" | "private";
invited_discord_ids: string[];
created_at: string; created_at: string;
events?: TripEvent[]; events?: TripEvent[];
} }
+81 -3
View File
@@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str):
# Cog # 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): class TripCommands(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
@@ -79,10 +89,11 @@ class TripCommands(commands.Cog):
self, interaction: discord.Interaction, current: str self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]: ) -> list[app_commands.Choice[str]]:
trips = await c2.get_trips() trips = await c2.get_trips()
user_id = str(interaction.user.id)
return [ return [
app_commands.Choice(name=t["name"], value=t["trip_id"]) app_commands.Choice(name=t["name"], value=t["trip_id"])
for t in trips for t in trips
if current.lower() in t["name"].lower() if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
][:25] ][:25]
async def event_autocomplete( async def event_autocomplete(
@@ -172,6 +183,9 @@ class TripCommands(commands.Cog):
today = date.today().strftime("%Y-%m-%d") today = date.today().strftime("%Y-%m-%d")
trips.sort(key=lambda t: t.get("start_date", "")) 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) embed = discord.Embed(title="Trips", color=0x2b2d31)
for t in trips[:10]: for t in trips[:10]:
upcoming = t.get("start_date", "") >= today upcoming = t.get("start_date", "") >= today
@@ -204,6 +218,9 @@ class TripCommands(commands.Cog):
if not data: if not data:
await interaction.followup.send("Trip not found.") await interaction.followup.send("Trip not found.")
return 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()) attendee_names = list(data.get("attendees", {}).values())
desc_lines = [ desc_lines = [
@@ -302,9 +319,11 @@ class TripCommands(commands.Cog):
@app_commands.autocomplete(trip=trip_autocomplete) @app_commands.autocomplete(trip=trip_autocomplete)
async def trip_join(self, interaction: discord.Interaction, trip: str): async def trip_join(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
if ok: if result is True:
await interaction.followup.send("You're on the trip!") 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: else:
await interaction.followup.send("Failed to join trip.") await interaction.followup.send("Failed to join trip.")
@@ -433,5 +452,64 @@ class TripCommands(commands.Cog):
await interaction.followup.send("Failed to leave event.") 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): async def setup(bot: commands.Bot):
await bot.add_cog(TripCommands(bot)) await bot.add_cog(TripCommands(bot))
@@ -112,7 +112,55 @@ class C2Client:
logger.error(f"C2 delete_trip failed: {e}") logger.error(f"C2 delete_trip failed: {e}")
return False return False
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool: async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 invite_to_trip failed: {e}")
return False
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.put(
f"{self.base}/trips/{trip_id}/visibility",
json={"visibility": visibility},
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 set_trip_visibility failed: {e}")
return False
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/auth/link",
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
headers=self._headers(),
)
if r.status_code == 404:
return {"error": "invalid_code"}
if r.status_code == 410:
return {"error": "expired"}
if r.status_code == 409:
return {"error": "already_linked"}
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 link_discord_account failed: {e}")
return {"error": "failed"}
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
"""Returns True on success, 'private' on 403, False on other errors."""
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.post( r = await client.post(
@@ -120,6 +168,8 @@ class C2Client:
json={"discord_user_id": user_id, "discord_username": username}, json={"discord_user_id": user_id, "discord_username": username},
headers=self._headers(), headers=self._headers(),
) )
if r.status_code == 403:
return "private"
r.raise_for_status() r.raise_for_status()
return True return True
except Exception as e: except Exception as e: