Files

294 lines
11 KiB
Python

"""
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/<bot>?start=<code>
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}