""" Telegram binding + preferences API. All mutating endpoints require a signed envelope from the wallet (same EIP-191 flow as set_hl_api_key). Read endpoints are unsigned but require the wallet in the path so other users' bindings stay private. GET /api/telegram/{wallet}/status ← unsigned read POST /api/telegram/{wallet}/init ← signed; returns binding code + deep link POST /api/telegram/{wallet}/preferences ← signed; update toggles POST /api/telegram/{wallet}/unbind ← signed; removes binding POST /api/telegram/{wallet}/test ← signed; sends a self-test """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.models import TelegramBinding, Subscription from app.services.signed_request import ( SignedReadCreds, optional_signed_read_creds, verify_signed_request) from app.services.telegram import send_test_message from app.services.telegram_bot import issue_binding_code, unbind_wallet router = APIRouter(prefix="/telegram", tags=["telegram"]) logger = logging.getLogger(__name__) ACTION_TG_INIT = "telegram_init" ACTION_TG_PREFS = "telegram_prefs" ACTION_TG_UNBIND = "telegram_unbind" ACTION_TG_TEST = "telegram_test" def _require_tg_configured() -> None: if not settings.telegram_bot_token or not settings.telegram_bot_username: raise HTTPException(503, "Telegram alerts are not configured on this server") # ── Schemas ────────────────────────────────────────────────────────────── class SignedEnvelope(BaseModel): wallet: str timestamp: int signature: str class PrefsBody(SignedEnvelope): alerts_enabled: Optional[bool] = None alert_trump: Optional[bool] = None alert_btc_bottom: Optional[bool] = None alert_funding: Optional[bool] = None alert_kol_divergence: Optional[bool] = None min_confidence: Optional[int] = Field(None, ge=0, le=100) mute_from_hour: Optional[int] = Field(None, ge=0, le=23) mute_until_hour: Optional[int] = Field(None, ge=0, le=23) class StatusResponse(BaseModel): configured: bool # whether server has bot token bot_username: Optional[str] = None bound: bool wallet_address: Optional[str] = None tg_username: Optional[str] = None chat_id: Optional[int] = None alerts_enabled: Optional[bool] = None alert_trump: Optional[bool] = None alert_btc_bottom: Optional[bool] = None alert_funding: Optional[bool] = None alert_kol_divergence: Optional[bool] = None min_confidence: Optional[int] = None mute_from_hour: Optional[int] = None mute_until_hour: Optional[int] = None total_alerts_sent: Optional[int] = None class InitResponse(BaseModel): code: str deep_link: str # t.me/?start= expires_in_seconds: int # ── Helpers ────────────────────────────────────────────────────────────── async def _build_status( wallet: str, db: AsyncSession, authenticated: bool = False, ) -> StatusResponse: """Shared status-building logic used by both the GET endpoint and update_preferences so both always return a consistent StatusResponse. B37: previously update_preferences called `await status(wallet, db)`, which passed db as the `timestamp` positional arg and left the DI-managed `db` param as a raw Depends() wrapper — crashing on `.execute()`. """ configured = bool(settings.telegram_bot_token and settings.telegram_bot_username) b = (await db.execute( select(TelegramBinding).where(TelegramBinding.wallet_address == wallet) )).scalar_one_or_none() if not b: return StatusResponse(configured=configured, bot_username=settings.telegram_bot_username or None, bound=False) if not authenticated: return StatusResponse( configured=configured, bot_username=settings.telegram_bot_username or None, bound=True, ) return StatusResponse( configured=configured, bot_username=settings.telegram_bot_username or None, bound=True, wallet_address=b.wallet_address, tg_username=b.tg_username, chat_id=b.chat_id, alerts_enabled=b.alerts_enabled, alert_trump=b.alert_trump, alert_btc_bottom=b.alert_btc_bottom, alert_funding=b.alert_funding, alert_kol_divergence=b.alert_kol_divergence, min_confidence=b.min_confidence, mute_from_hour=b.mute_from_hour, mute_until_hour=b.mute_until_hour, total_alerts_sent=b.total_alerts_sent, ) # ── Endpoints ──────────────────────────────────────────────────────────── @router.get("/{wallet}/status", response_model=StatusResponse) async def status( wallet: str, creds: Optional[SignedReadCreds] = Depends(optional_signed_read_creds), db: AsyncSession = Depends(get_db), ) -> StatusResponse: """Wallet Telegram status. Unauthenticated: returns configured + bound (boolean only). Authenticated (timestamp + signature): returns full binding details. This prevents third parties from de-anonymising wallets via tg_username/chat_id. """ wallet = wallet.lower().strip() # Verify ownership if credentials provided (best-effort — never block on failure). # Accept either "view_telegram_status" (dedicated action) or "view_user" # (broad read action that the frontend already caches for other endpoints). # Accepting view_user avoids requiring a separate wallet signature just to # see Telegram status — the frontend reuses its cached view_user envelope. authenticated = False if creds is not None: for _action in ("view_telegram_status", "view_user"): try: verify_signed_request( action=_action, wallet=wallet, timestamp_ms=creds.ts, signature=creds.sig, body=None, allow_replay=True, ) authenticated = True break except Exception: pass # Try next action; fall through to redacted response if all fail return await _build_status(wallet, db, authenticated=authenticated) @router.post("/{wallet}/init", response_model=InitResponse) async def init_binding( wallet: str, body: SignedEnvelope, db: AsyncSession = Depends(get_db), ) -> InitResponse: """Generate a one-time code and the deep link the frontend renders. Subscriber-gated — only paying wallets can receive alerts.""" _require_tg_configured() wallet = wallet.lower().strip() if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch") verify_signed_request( action=ACTION_TG_INIT, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=None, ) sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == wallet) )).scalar_one_or_none() if not sub or not sub.active: raise HTTPException(403, "Wallet must be subscribed to enable Telegram alerts") code = issue_binding_code(wallet) deep_link = f"https://t.me/{settings.telegram_bot_username}?start={code}" return InitResponse(code=code, deep_link=deep_link, expires_in_seconds=600) @router.post("/{wallet}/preferences", response_model=StatusResponse) async def update_preferences( wallet: str, body: PrefsBody, db: AsyncSession = Depends(get_db), ) -> StatusResponse: """Toggle alert sources, confidence floor, mute hours. Signed. Idempotent — only fields present in the body are updated.""" _require_tg_configured() wallet = wallet.lower().strip() if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch") # Build a canonical body for signing: include only the fields the user # is actually trying to change (so the signed payload matches what the # frontend hashed). signed_body = {k: v for k, v in body.model_dump(exclude={"wallet", "timestamp", "signature"}).items() if v is not None} verify_signed_request( action=ACTION_TG_PREFS, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=signed_body, ) b = (await db.execute( select(TelegramBinding).where(TelegramBinding.wallet_address == wallet) )).scalar_one_or_none() if not b: raise HTTPException(404, "No Telegram binding for this wallet — bind via /start first") values = {} for f in ["alerts_enabled", "alert_trump", "alert_btc_bottom", "alert_funding", "alert_kol_divergence", "min_confidence", "mute_from_hour", "mute_until_hour"]: v = getattr(body, f) if v is not None: values[f] = v if values: await db.execute( update(TelegramBinding).where(TelegramBinding.id == b.id).values(**values) ) await db.commit() await db.refresh(b) # Return full authenticated status — the caller already proved ownership # via the signed request verified above (B37: was `await status(wallet, db)` # which passed db as the timestamp arg, crashing inside status()). return await _build_status(wallet, db, authenticated=True) @router.post("/{wallet}/unbind") async def unbind( wallet: str, body: SignedEnvelope, db: AsyncSession = Depends(get_db), ) -> dict: _require_tg_configured() wallet = wallet.lower().strip() if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch") verify_signed_request( action=ACTION_TG_UNBIND, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=None, ) n = await unbind_wallet(wallet) return {"removed": n} @router.post("/{wallet}/test") async def test( wallet: str, body: SignedEnvelope, db: AsyncSession = Depends(get_db), ) -> dict: """Send a sample alert to verify the binding works end-to-end.""" _require_tg_configured() wallet = wallet.lower().strip() if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch") verify_signed_request( action=ACTION_TG_TEST, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=None, ) ok = await send_test_message(wallet) if not ok: raise HTTPException(400, "Test failed — bind via /start first, or check bot token") return {"sent": True}