"""Bot API key management and project report endpoints.""" from __future__ import annotations from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from sqlmodel import col, desc, select from app.api.deps import require_org_admin, require_org_member from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token from app.core.time import utcnow from app.db.session import get_session from app.models.bot import BotApiKey, BotReport from app.schemas.bot import ( BotApiKeyCreate, BotApiKeyCreated, BotApiKeyRead, BotReportCreate, BotReportRead, ) from app.services.organizations import OrganizationContext if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession router = APIRouter(prefix="/bot", tags=["bot"]) SESSION_DEP = Depends(get_session) ORG_MEMBER_DEP = Depends(require_org_member) ORG_ADMIN_DEP = Depends(require_org_admin) async def _get_bot_key_auth( x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"), session: AsyncSession = SESSION_DEP, ) -> BotApiKey: """Validate X-Bot-Key header against stored hashes. Returns the matching key row.""" if not x_bot_key: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.") keys = list(await session.exec(select(BotApiKey))) for key in keys: if verify_agent_token(x_bot_key, key.key_hash): key.last_used_at = utcnow() session.add(key) await session.commit() return key raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.") BOT_KEY_DEP = Depends(_get_bot_key_auth) # --------------------------------------------------------------------------- # Key management (user auth) # --------------------------------------------------------------------------- @router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED) async def create_bot_key( data: BotApiKeyCreate, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> BotApiKeyCreated: """Generate a new bot API key. The plaintext key is returned once — store it immediately.""" plaintext = generate_agent_token() key = BotApiKey( organization_id=ctx.organization.id, name=data.name, key_hash=hash_agent_token(plaintext), key_last_four=plaintext[-4:], ) session.add(key) await session.commit() await session.refresh(key) return BotApiKeyCreated( id=key.id, name=key.name, key_last_four=key.key_last_four, created_at=key.created_at, last_used_at=key.last_used_at, key=plaintext, ) @router.get("/keys", response_model=list[BotApiKeyRead]) async def list_bot_keys( session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> list[BotApiKeyRead]: """List all bot API keys for this organization.""" keys = list(await session.exec( select(BotApiKey) .where(col(BotApiKey.organization_id) == ctx.organization.id) .order_by(desc(col(BotApiKey.created_at))) )) return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys] @router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_bot_key( key_id: UUID, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> None: """Revoke a bot API key.""" key = await session.get(BotApiKey, key_id) if key is None or key.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) await session.delete(key) await session.commit() # --------------------------------------------------------------------------- # Reporting (bot key auth) # --------------------------------------------------------------------------- @router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED) async def create_bot_report( data: BotReportCreate, session: AsyncSession = SESSION_DEP, key: BotApiKey = BOT_KEY_DEP, ) -> BotReportRead: """Record what the bot is currently working on.""" report = BotReport( organization_id=key.organization_id, key_id=key.id, project=data.project, task=data.task, status=data.status, detail=data.detail, ) session.add(report) await session.commit() await session.refresh(report) return BotReportRead.model_validate(report, from_attributes=True) # --------------------------------------------------------------------------- # Report reading (user auth) # --------------------------------------------------------------------------- @router.get("/reports/latest", response_model=BotReportRead) async def get_latest_bot_report( session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> BotReportRead: """Return the most recent bot report for this organization.""" report = (await session.exec( select(BotReport) .where(col(BotReport.organization_id) == ctx.organization.id) .order_by(desc(col(BotReport.reported_at))) .limit(1) )).first() if report is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return BotReportRead.model_validate(report, from_attributes=True) @router.get("/reports", response_model=list[BotReportRead]) async def list_bot_reports( limit: int = Query(default=50, ge=1, le=200), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> list[BotReportRead]: """List bot reports newest-first.""" reports = list(await session.exec( select(BotReport) .where(col(BotReport.organization_id) == ctx.organization.id) .order_by(desc(col(BotReport.reported_at))) .limit(limit) )) return [BotReportRead.model_validate(r, from_attributes=True) for r in reports]