294 lines
11 KiB
Python
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}
|