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:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
+232
View File
@@ -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}