Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:
KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.
Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.
BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.
WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.
Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.
Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.
New ops scripts —
- scripts/preflight.py: env/DB/Telegram/AI auth verification gate
- scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
- scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder
15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
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/<bot>?start=<code>
|
||||
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}
|
||||
Reference in New Issue
Block a user