# Identity Endpoints for Account Linking (Telegram ↔ Energy Union) # This file is appended to main.py import secrets from datetime import datetime, timedelta from pydantic import BaseModel from typing import Optional # ============================================================================ # IDENTITY & ACCOUNT LINKING # ============================================================================ class LinkStartRequest(BaseModel): account_id: str # UUID as string ttl_minutes: int = 10 class LinkStartResponse(BaseModel): link_code: str expires_at: datetime class ResolveResponse(BaseModel): account_id: Optional[str] = None linked: bool = False linked_at: Optional[datetime] = None @app.post("/identity/link/start", response_model=LinkStartResponse) async def start_link(request: LinkStartRequest): """ Generate a one-time link code for account linking. This is called from Energy Union dashboard when user clicks "Link Telegram". """ try: # Generate secure random code link_code = secrets.token_urlsafe(16)[:20].upper() expires_at = datetime.utcnow() + timedelta(minutes=request.ttl_minutes) # Store in database await db.pool.execute( """ INSERT INTO link_codes (code, account_id, expires_at, generated_via) VALUES ($1, $2::uuid, $3, 'api') """, link_code, request.account_id, expires_at ) logger.info("link_code_generated", account_id=request.account_id, code=link_code[:4] + "***") return LinkStartResponse( link_code=link_code, expires_at=expires_at ) except Exception as e: logger.error("link_code_generation_failed", error=str(e)) raise HTTPException(status_code=500, detail=str(e)) @app.get("/identity/resolve", response_model=ResolveResponse) async def resolve_telegram(telegram_user_id: int): """ Resolve Telegram user ID to Energy Union account ID. Returns null account_id if not linked. """ try: row = await db.pool.fetchrow( """ SELECT account_id, linked_at FROM account_links WHERE telegram_user_id = $1 AND status = 'active' """, telegram_user_id ) if row: return ResolveResponse( account_id=str(row['account_id']), linked=True, linked_at=row['linked_at'] ) else: return ResolveResponse(linked=False) except Exception as e: logger.error("telegram_resolve_failed", error=str(e)) raise HTTPException(status_code=500, detail=str(e)) @app.get("/identity/user/{account_id}/timeline") async def get_user_timeline( account_id: str, limit: int = Query(default=20, le=100), channel: Optional[str] = None ): """ Get user's interaction timeline across all channels. Only available for linked accounts. """ try: if channel: rows = await db.pool.fetch( """ SELECT id, channel, channel_id, event_type, summary, metadata, importance_score, event_at FROM user_timeline WHERE account_id = $1::uuid AND channel = $2 ORDER BY event_at DESC LIMIT $3 """, account_id, channel, limit ) else: rows = await db.pool.fetch( """ SELECT id, channel, channel_id, event_type, summary, metadata, importance_score, event_at FROM user_timeline WHERE account_id = $1::uuid ORDER BY event_at DESC LIMIT $3 """, account_id, limit ) events = [] for row in rows: events.append({ "id": str(row['id']), "channel": row['channel'], "channel_id": row['channel_id'], "event_type": row['event_type'], "summary": row['summary'], "metadata": row['metadata'] or {}, "importance_score": row['importance_score'], "event_at": row['event_at'].isoformat() }) return {"events": events, "account_id": account_id, "count": len(events)} except Exception as e: logger.error("timeline_fetch_failed", error=str(e), account_id=account_id) raise HTTPException(status_code=500, detail=str(e)) @app.post("/identity/timeline/add") async def add_timeline_event( account_id: str, channel: str, channel_id: str, event_type: str, summary: str, metadata: Optional[dict] = None, importance_score: float = 0.5 ): """ Add an event to user's timeline. Called by Gateway when processing messages from linked accounts. """ try: event_id = await db.pool.fetchval( """ SELECT add_timeline_event($1::uuid, $2, $3, $4, $5, $6::jsonb, $7) """, account_id, channel, channel_id, event_type, summary, metadata or {}, importance_score ) return {"event_id": str(event_id), "success": True} except Exception as e: logger.error("timeline_add_failed", error=str(e)) raise HTTPException(status_code=500, detail=str(e))