Pipeline/backend/app/api/bot.py

174 lines
6.0 KiB
Python
Raw Normal View History

2026-05-26 15:41:24 -05:00
"""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]