""" 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 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 # ── Endpoints ──────────────────────────────────────────────────────────── @router.get("/{wallet}/status", response_model=StatusResponse) async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusResponse: """Public-by-wallet read. Returns whether server is configured AND whether this wallet has bound a Telegram chat.""" wallet = wallet.lower().strip() 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) 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, ) @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 await status(wallet, db) @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}