add trips permissions
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, trips, places
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links
|
||||
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(upload.router) # auth is per-node, handled inline
|
||||
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")
|
||||
|
||||
@@ -148,6 +148,8 @@ class TripCreate(BaseModel):
|
||||
end_date: str # YYYY-MM-DD
|
||||
available_tags: List[str] = [] # tag labels configured for this trip
|
||||
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):
|
||||
|
||||
@@ -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}
|
||||
@@ -18,6 +18,32 @@ from app.internal.auth import (
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trips():
|
||||
return await fstore.collection_list("trips")
|
||||
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||
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("")
|
||||
@@ -209,6 +239,8 @@ async def create_trip(body: TripCreate):
|
||||
"attendees": {}, # {discord_user_id: discord_username}
|
||||
"available_tags": body.available_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,
|
||||
}
|
||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||
@@ -216,12 +248,17 @@ async def create_trip(body: TripCreate):
|
||||
|
||||
|
||||
@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)
|
||||
if not trip:
|
||||
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.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}
|
||||
|
||||
|
||||
@@ -259,12 +296,49 @@ async def join_trip(
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
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[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.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")
|
||||
async def leave_trip(
|
||||
trip_id: str,
|
||||
|
||||
@@ -908,6 +908,7 @@ export default function TripDetailPage() {
|
||||
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
||||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [inviteInput, setInviteInput] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -1024,6 +1025,28 @@ export default function TripDetailPage() {
|
||||
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) {
|
||||
if (!trip) return;
|
||||
const current = trip.overlap_tags ?? [];
|
||||
@@ -1074,7 +1097,13 @@ export default function TripDetailPage() {
|
||||
</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 && (
|
||||
<>
|
||||
<button
|
||||
@@ -1083,6 +1112,12 @@ export default function TripDetailPage() {
|
||||
>
|
||||
+ Add Event
|
||||
</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
|
||||
onClick={handleDeleteTrip}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||
@@ -1131,6 +1166,39 @@ export default function TripDetailPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Two-column layout */}
|
||||
|
||||
@@ -144,6 +144,18 @@ export const c2api = {
|
||||
request(`/trips/${id}`, { method: "DELETE" }),
|
||||
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 }) }),
|
||||
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) =>
|
||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||
|
||||
@@ -135,6 +135,8 @@ export interface TripRecord {
|
||||
attendees: Record<string, string>;
|
||||
available_tags: string[];
|
||||
overlap_tags: string[];
|
||||
visibility: "public" | "private";
|
||||
invited_discord_ids: string[];
|
||||
created_at: string;
|
||||
events?: TripEvent[];
|
||||
}
|
||||
|
||||
@@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str):
|
||||
# Cog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
|
||||
if trip.get("visibility", "public") == "public":
|
||||
return True
|
||||
if discord_user_id in trip.get("attendees", {}):
|
||||
return True
|
||||
if discord_user_id in trip.get("invited_discord_ids", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TripCommands(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
@@ -79,10 +89,11 @@ class TripCommands(commands.Cog):
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
trips = await c2.get_trips()
|
||||
user_id = str(interaction.user.id)
|
||||
return [
|
||||
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
||||
for t in trips
|
||||
if current.lower() in t["name"].lower()
|
||||
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
|
||||
][:25]
|
||||
|
||||
async def event_autocomplete(
|
||||
@@ -172,6 +183,9 @@ class TripCommands(commands.Cog):
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
trips.sort(key=lambda t: t.get("start_date", ""))
|
||||
|
||||
user_id = str(interaction.user.id)
|
||||
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
|
||||
|
||||
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
||||
for t in trips[:10]:
|
||||
upcoming = t.get("start_date", "") >= today
|
||||
@@ -204,6 +218,9 @@ class TripCommands(commands.Cog):
|
||||
if not data:
|
||||
await interaction.followup.send("Trip not found.")
|
||||
return
|
||||
if not _user_can_see_trip(data, str(interaction.user.id)):
|
||||
await interaction.followup.send("This trip is private.", ephemeral=True)
|
||||
return
|
||||
|
||||
attendee_names = list(data.get("attendees", {}).values())
|
||||
desc_lines = [
|
||||
@@ -302,9 +319,11 @@ class TripCommands(commands.Cog):
|
||||
@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:
|
||||
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
||||
if result is True:
|
||||
await interaction.followup.send("You're on the trip!")
|
||||
elif result == "private":
|
||||
await interaction.followup.send("This trip is private — you need an invite to join.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to join trip.")
|
||||
|
||||
@@ -433,5 +452,64 @@ class TripCommands(commands.Cog):
|
||||
await interaction.followup.send("Failed to leave event.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip invite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
|
||||
@app_commands.describe(trip="The trip.", user="The user to invite.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.invite_to_trip(trip, str(user.id))
|
||||
if ok:
|
||||
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to send invite.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip privacy
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="privacy", description="Set a trip to public or private.")
|
||||
@app_commands.describe(trip="The trip.", visibility="public or private")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
@app_commands.choices(visibility=[
|
||||
app_commands.Choice(name="Public — anyone can see and join", value="public"),
|
||||
app_commands.Choice(name="Private — invite only", value="private"),
|
||||
])
|
||||
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.set_trip_visibility(trip, visibility)
|
||||
if ok:
|
||||
await interaction.followup.send(f"Trip is now **{visibility}**.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to update trip privacy.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /link
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
|
||||
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
|
||||
async def link_account(self, interaction: discord.Interaction, code: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
result = await c2.link_discord_account(
|
||||
code.upper().strip(),
|
||||
str(interaction.user.id),
|
||||
interaction.user.display_name,
|
||||
)
|
||||
if "error" in result:
|
||||
msgs = {
|
||||
"invalid_code": "Invalid code. Generate a new one from the web app.",
|
||||
"expired": "Code has expired. Generate a new one from the web app.",
|
||||
"already_linked": "This Discord account is already linked to a different web account.",
|
||||
"failed": "Something went wrong. Try again.",
|
||||
}
|
||||
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
|
||||
else:
|
||||
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(TripCommands(bot))
|
||||
|
||||
@@ -112,7 +112,55 @@ class C2Client:
|
||||
logger.error(f"C2 delete_trip failed: {e}")
|
||||
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:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
@@ -120,6 +168,8 @@ class C2Client:
|
||||
json={"discord_user_id": user_id, "discord_username": username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if r.status_code == 403:
|
||||
return "private"
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user