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,564 @@
|
||||
"""
|
||||
Telegram bot worker — long-polls getUpdates and handles user commands.
|
||||
|
||||
Commands supported:
|
||||
|
||||
/start CODE → bind this chat_id to the wallet that owns CODE
|
||||
/start → friendly hello with instructions
|
||||
/stop → disable alerts (keeps the binding so re-enable is one tap)
|
||||
/status → show binding status and current preferences
|
||||
/test → send self a test message to verify formatting
|
||||
|
||||
This runs as a single background asyncio task started from main.py lifespan.
|
||||
Only one instance should run at a time — Telegram's long-poll model assumes
|
||||
exactly one consumer per bot token. If you horizontally scale the API, switch
|
||||
to webhook mode (see set_webhook in the Bot API).
|
||||
|
||||
The one-time binding codes live in a process-local dict with a 10-minute
|
||||
TTL. On API restart all pending codes evaporate (the user re-clicks Connect).
|
||||
This is intentional — codes never touch the DB so a compromised dump can't
|
||||
hijack future bindings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.database import AsyncSessionLocal as async_session
|
||||
from app.models import TelegramBinding, Subscription
|
||||
from app.services.telegram import send_message, TG_API
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── One-time binding codes ────────────────────────────────────────────────
|
||||
|
||||
|
||||
_CODE_TTL_SECONDS = 10 * 60
|
||||
_CODE_ALPHABET = string.ascii_uppercase + string.digits # 36^6 ≈ 2.1B
|
||||
_CODE_LEN = 6
|
||||
# code → (wallet_lower, expires_at)
|
||||
_pending_codes: dict[str, tuple[str, datetime]] = {}
|
||||
|
||||
|
||||
def _purge_expired_codes() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [c for c, (_, exp) in _pending_codes.items() if exp < now]
|
||||
for c in expired:
|
||||
_pending_codes.pop(c, None)
|
||||
|
||||
|
||||
def issue_binding_code(wallet: str) -> str:
|
||||
"""Generate a fresh 6-char code for a wallet. If the same wallet calls
|
||||
twice within the TTL, the old code is invalidated — only the latest
|
||||
code works (prevents stale shared links)."""
|
||||
_purge_expired_codes()
|
||||
wallet_l = wallet.lower()
|
||||
# Invalidate any previous code for this wallet
|
||||
for c, (w, _) in list(_pending_codes.items()):
|
||||
if w == wallet_l:
|
||||
_pending_codes.pop(c, None)
|
||||
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||||
exp = datetime.now(timezone.utc) + timedelta(seconds=_CODE_TTL_SECONDS)
|
||||
_pending_codes[code] = (wallet_l, exp)
|
||||
return code
|
||||
|
||||
|
||||
def _consume_code(code: str) -> Optional[str]:
|
||||
"""Resolve and remove a code. Returns the wallet, or None if invalid/expired."""
|
||||
_purge_expired_codes()
|
||||
rec = _pending_codes.pop(code.upper(), None)
|
||||
if rec is None:
|
||||
return None
|
||||
return rec[0]
|
||||
|
||||
|
||||
# ── Command handlers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
HELP_TEXT = (
|
||||
"👋 <b>Trump Alpha bot</b>\n\n"
|
||||
"Push alerts for high-conviction crypto signals — Trump posts, BTC bottom "
|
||||
"reversals, funding extremes, KOL divergence.\n\n"
|
||||
"<b>Just press /start</b> — you're subscribed. No wallet needed.\n\n"
|
||||
"<b>Configure:</b>\n"
|
||||
" /trump on|off Trump · Truth Social posts\n"
|
||||
" /btc on|off BTC · macro bottom\n"
|
||||
" /funding on|off BTC · funding reversal\n"
|
||||
" /kol on|off KOL · talks vs trades\n"
|
||||
" /conf 0-100 min AI confidence (Trump only)\n"
|
||||
" /quiet 23 7 mute hours UTC (or 'off' to disable)\n\n"
|
||||
"<b>Status & control:</b>\n"
|
||||
" /status — show your preferences\n"
|
||||
" /stop — pause all alerts\n"
|
||||
" /test — send a sample alert\n\n"
|
||||
"<b>Pro features</b> (auto-trade on Hyperliquid):\n"
|
||||
"Open the dashboard → <i>Settings</i> → <i>Connect Telegram</i> to link "
|
||||
"your wallet."
|
||||
)
|
||||
|
||||
|
||||
# Default preferences for a freshly-created walletless binding.
|
||||
_DEFAULT_PREFS = {
|
||||
"alerts_enabled": True,
|
||||
"alert_trump": True,
|
||||
"alert_btc_bottom": True,
|
||||
"alert_funding": True,
|
||||
"alert_kol_divergence": False,
|
||||
"min_confidence": 70,
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_binding(chat_id: int, username: Optional[str]) -> TelegramBinding:
|
||||
"""Create or refresh a walletless binding for this chat. Returns the
|
||||
binding (always with id set)."""
|
||||
async with async_session() as db:
|
||||
existing = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
|
||||
)).scalar_one_or_none()
|
||||
if existing:
|
||||
# Refresh username + re-enable alerts on subsequent /start
|
||||
await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.id == existing.id)
|
||||
.values(tg_username=username, alerts_enabled=True)
|
||||
)
|
||||
await db.commit()
|
||||
return existing
|
||||
b = TelegramBinding(
|
||||
wallet_address=None,
|
||||
chat_id=chat_id,
|
||||
tg_username=username,
|
||||
**_DEFAULT_PREFS,
|
||||
)
|
||||
db.add(b)
|
||||
await db.commit()
|
||||
await db.refresh(b)
|
||||
return b
|
||||
|
||||
|
||||
async def _cmd_start(chat_id: int, username: Optional[str], arg: str) -> None:
|
||||
"""
|
||||
/start → free-tier walletless binding (anyone can subscribe)
|
||||
/start CODE → upgrade to wallet-linked binding (Pro features)
|
||||
"""
|
||||
arg = arg.strip()
|
||||
|
||||
# No arg → free-tier subscribe. Always idempotent.
|
||||
if not arg:
|
||||
b = await _ensure_binding(chat_id, username)
|
||||
if b.wallet_address:
|
||||
await send_message(
|
||||
chat_id,
|
||||
"✅ Already linked — you're getting alerts.\n"
|
||||
"Send /status to see your settings, /help for commands.",
|
||||
)
|
||||
else:
|
||||
await send_message(
|
||||
chat_id,
|
||||
"🎉 <b>You're subscribed!</b>\n\n"
|
||||
"You'll get alerts for: Trump posts, BTC bottom signals, "
|
||||
"funding reversals. KOL divergence is off by default (noisier).\n\n"
|
||||
"Type /help to see every command, or /test to preview an alert.",
|
||||
)
|
||||
return
|
||||
|
||||
# Arg present → wallet-binding flow (Pro)
|
||||
wallet = _consume_code(arg)
|
||||
if not wallet:
|
||||
await send_message(
|
||||
chat_id,
|
||||
"❌ <b>Invalid or expired code.</b>\n\n"
|
||||
"The 6-char code only comes from the dashboard's "
|
||||
"<i>Settings → Connect Telegram</i> panel — and it expires after "
|
||||
"10 minutes.\n\n"
|
||||
"Don't need wallet features? Just send /start (no code) — alerts "
|
||||
"are free for everyone.",
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session() as db:
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||
)).scalar_one_or_none()
|
||||
if not sub or not sub.active:
|
||||
await send_message(
|
||||
chat_id,
|
||||
"⚠️ <b>This wallet isn't subscribed yet.</b>\n\n"
|
||||
"Open Settings on the dashboard, click <i>Start trading</i>, "
|
||||
"then come back and re-connect.",
|
||||
)
|
||||
return
|
||||
|
||||
existing_by_wallet = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
|
||||
)).scalar_one_or_none()
|
||||
existing_by_chat = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Edge: chat already bound to a DIFFERENT wallet — reject loudly.
|
||||
if existing_by_chat and existing_by_chat.wallet_address and \
|
||||
existing_by_chat.wallet_address != wallet:
|
||||
await send_message(
|
||||
chat_id,
|
||||
"❌ This Telegram account is already bound to a different "
|
||||
"wallet. Run /stop on that wallet first (or unbind via the "
|
||||
"dashboard).",
|
||||
)
|
||||
return
|
||||
|
||||
if existing_by_wallet and existing_by_wallet.chat_id != chat_id:
|
||||
# Wallet was previously bound to a different chat — move it here.
|
||||
# Also clean up any walletless binding for this chat to avoid a
|
||||
# duplicate row.
|
||||
if existing_by_chat:
|
||||
from sqlalchemy import delete as _delete
|
||||
await db.execute(_delete(TelegramBinding).where(
|
||||
TelegramBinding.id == existing_by_chat.id))
|
||||
await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.id == existing_by_wallet.id)
|
||||
.values(chat_id=chat_id, tg_username=username,
|
||||
alerts_enabled=True,
|
||||
bound_at=datetime.now(timezone.utc))
|
||||
)
|
||||
elif existing_by_chat:
|
||||
# Free user is upgrading to wallet-linked — just attach the wallet
|
||||
# to their existing walletless binding, keep their preferences.
|
||||
await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.id == existing_by_chat.id)
|
||||
.values(wallet_address=wallet, tg_username=username,
|
||||
alerts_enabled=True,
|
||||
bound_at=datetime.now(timezone.utc))
|
||||
)
|
||||
else:
|
||||
# Brand-new wallet + chat.
|
||||
db.add(TelegramBinding(
|
||||
wallet_address=wallet, chat_id=chat_id,
|
||||
tg_username=username, **_DEFAULT_PREFS,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
short = wallet[:6] + "…" + wallet[-4:]
|
||||
await send_message(
|
||||
chat_id,
|
||||
f"✅ <b>Wallet linked: {short}</b>\n\n"
|
||||
"Pro features unlocked. Your existing alert preferences are kept. "
|
||||
"Manage everything from the dashboard's Settings page or via bot "
|
||||
"commands. Send /status to see your current setup.",
|
||||
)
|
||||
|
||||
|
||||
# ── Preference commands ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _set_pref(chat_id: int, field: str, value: bool | int) -> bool:
|
||||
"""Returns True if the binding existed and was updated."""
|
||||
async with async_session() as db:
|
||||
r = await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.chat_id == chat_id)
|
||||
.values(**{field: value})
|
||||
)
|
||||
await db.commit()
|
||||
return r.rowcount > 0
|
||||
|
||||
|
||||
def _parse_on_off(arg: str) -> Optional[bool]:
|
||||
a = arg.strip().lower()
|
||||
if a in ("on", "yes", "enable", "1", "true"): return True
|
||||
if a in ("off", "no", "disable", "0", "false"): return False
|
||||
return None
|
||||
|
||||
|
||||
async def _cmd_toggle(chat_id: int, field: str, label: str, arg: str) -> None:
|
||||
v = _parse_on_off(arg)
|
||||
if v is None:
|
||||
await send_message(chat_id,
|
||||
f"Usage: <code>{label.lower().split()[0]} on</code> or "
|
||||
f"<code>{label.lower().split()[0]} off</code>")
|
||||
return
|
||||
ok = await _set_pref(chat_id, field, v)
|
||||
if not ok:
|
||||
await send_message(chat_id, "No binding here. Send /start first.")
|
||||
return
|
||||
state = "🟢 ON" if v else "🔴 OFF"
|
||||
await send_message(chat_id, f"{state} · {label}")
|
||||
|
||||
|
||||
async def _cmd_conf(chat_id: int, arg: str) -> None:
|
||||
try:
|
||||
n = int(arg.strip())
|
||||
if not 0 <= n <= 100:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await send_message(chat_id,
|
||||
"Usage: <code>/conf 70</code> (0–100). Only Trump posts above "
|
||||
"this AI confidence will trigger alerts.")
|
||||
return
|
||||
ok = await _set_pref(chat_id, "min_confidence", n)
|
||||
if not ok:
|
||||
await send_message(chat_id, "No binding here. Send /start first.")
|
||||
return
|
||||
await send_message(chat_id, f"Min confidence set to <b>{n}</b>.")
|
||||
|
||||
|
||||
async def _cmd_quiet(chat_id: int, arg: str) -> None:
|
||||
a = arg.strip().lower()
|
||||
if a in ("off", "none", "disable", ""):
|
||||
async with async_session() as db:
|
||||
r = await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.chat_id == chat_id)
|
||||
.values(mute_from_hour=None, mute_until_hour=None)
|
||||
)
|
||||
await db.commit()
|
||||
if not r.rowcount:
|
||||
await send_message(chat_id, "No binding here. Send /start first.")
|
||||
return
|
||||
await send_message(chat_id, "🔔 Quiet hours disabled.")
|
||||
return
|
||||
|
||||
parts = arg.split()
|
||||
if len(parts) != 2:
|
||||
await send_message(chat_id,
|
||||
"Usage: <code>/quiet 23 7</code> (UTC, e.g. mute 23:00–07:00) "
|
||||
"or <code>/quiet off</code>.")
|
||||
return
|
||||
try:
|
||||
a_h, b_h = int(parts[0]), int(parts[1])
|
||||
if not (0 <= a_h <= 23 and 0 <= b_h <= 23):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await send_message(chat_id, "Hours must be integers 0–23.")
|
||||
return
|
||||
|
||||
async with async_session() as db:
|
||||
r = await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.chat_id == chat_id)
|
||||
.values(mute_from_hour=a_h, mute_until_hour=b_h)
|
||||
)
|
||||
await db.commit()
|
||||
if not r.rowcount:
|
||||
await send_message(chat_id, "No binding here. Send /start first.")
|
||||
return
|
||||
await send_message(chat_id, f"🌙 Quiet hours: {a_h:02d}:00–{b_h:02d}:00 UTC.")
|
||||
|
||||
|
||||
async def _cmd_stop(chat_id: int) -> None:
|
||||
async with async_session() as db:
|
||||
r = await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.chat_id == chat_id)
|
||||
.values(alerts_enabled=False)
|
||||
)
|
||||
await db.commit()
|
||||
if r.rowcount:
|
||||
await send_message(chat_id,
|
||||
"🔕 Alerts paused. Send /start any time to re-enable.")
|
||||
else:
|
||||
await send_message(chat_id,
|
||||
"You don't have an active binding here. Send /start to set one up.")
|
||||
|
||||
|
||||
async def _cmd_status(chat_id: int) -> None:
|
||||
async with async_session() as db:
|
||||
b = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
|
||||
)).scalar_one_or_none()
|
||||
if not b:
|
||||
await send_message(chat_id,
|
||||
"Not subscribed yet. Send /start to begin (no wallet required).")
|
||||
return
|
||||
|
||||
if b.wallet_address:
|
||||
short = b.wallet_address[:6] + "…" + b.wallet_address[-4:]
|
||||
tier_line = f"Tier: <b>Pro</b> · wallet <code>{short}</code>"
|
||||
else:
|
||||
tier_line = "Tier: <b>Free</b> · no wallet linked"
|
||||
|
||||
on = "🟢 ON" if b.alerts_enabled else "🔴 OFF"
|
||||
srcs = []
|
||||
if b.alert_trump: srcs.append("Trump")
|
||||
if b.alert_btc_bottom: srcs.append("BTC bottom")
|
||||
if b.alert_funding: srcs.append("Funding")
|
||||
if b.alert_kol_divergence: srcs.append("KOL")
|
||||
src_line = ", ".join(srcs) or "(none — alerts will be silent)"
|
||||
mute = ""
|
||||
if b.mute_from_hour is not None and b.mute_until_hour is not None:
|
||||
mute = f"\n🌙 Quiet hours: {b.mute_from_hour:02d}–{b.mute_until_hour:02d} UTC"
|
||||
await send_message(
|
||||
chat_id,
|
||||
f"📡 <b>Status</b>\n\n"
|
||||
f"{tier_line}\n"
|
||||
f"Alerts: {on}\n"
|
||||
f"Sources: {src_line}\n"
|
||||
f"Min confidence: {b.min_confidence}{mute}\n\n"
|
||||
f"Sent: {b.total_alerts_sent} · Failed: {b.total_alerts_failed}\n\n"
|
||||
f"Toggle anything with /trump /btc /funding /kol /conf /quiet — "
|
||||
f"send /help for the full list.",
|
||||
)
|
||||
|
||||
|
||||
async def _cmd_test(chat_id: int) -> None:
|
||||
async with async_session() as db:
|
||||
b = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
|
||||
)).scalar_one_or_none()
|
||||
if not b:
|
||||
await send_message(chat_id, "Send /start first to subscribe (no wallet needed).")
|
||||
return
|
||||
await send_message(
|
||||
chat_id,
|
||||
"🟢 <b>BTC · BUY</b> · conf <b>88</b>\n"
|
||||
"<i>Trump · Truth Social</i>\n\n"
|
||||
"BITCOIN is the FUTURE of money. America will LEAD the world in "
|
||||
"crypto. BIG things coming very soon!\n\n"
|
||||
"(this is a test message — your actual alerts will look like this)",
|
||||
)
|
||||
|
||||
|
||||
# ── Long-poll loop ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_message(msg: dict) -> None:
|
||||
chat = msg.get("chat") or {}
|
||||
chat_id = chat.get("id")
|
||||
if not chat_id:
|
||||
return
|
||||
text = (msg.get("text") or "").strip()
|
||||
if not text:
|
||||
return
|
||||
from_user = msg.get("from") or {}
|
||||
username = from_user.get("username")
|
||||
|
||||
# Parse first word as command
|
||||
parts = text.split(maxsplit=1)
|
||||
cmd = parts[0].lower()
|
||||
arg = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Normalize /command@botname (group-chat syntax) → /command
|
||||
if "@" in cmd:
|
||||
cmd = cmd.split("@", 1)[0]
|
||||
|
||||
try:
|
||||
if cmd == "/start":
|
||||
await _cmd_start(chat_id, username, arg)
|
||||
elif cmd in ("/stop", "/pause"):
|
||||
await _cmd_stop(chat_id)
|
||||
elif cmd in ("/status", "/me"):
|
||||
await _cmd_status(chat_id)
|
||||
elif cmd == "/test":
|
||||
await _cmd_test(chat_id)
|
||||
elif cmd in ("/help", "/?"):
|
||||
await send_message(chat_id, HELP_TEXT)
|
||||
# ── source toggles ───────────────────────────────────────────
|
||||
elif cmd == "/trump":
|
||||
await _cmd_toggle(chat_id, "alert_trump", "Trump alerts", arg)
|
||||
elif cmd == "/btc":
|
||||
await _cmd_toggle(chat_id, "alert_btc_bottom", "BTC bottom alerts", arg)
|
||||
elif cmd == "/funding":
|
||||
await _cmd_toggle(chat_id, "alert_funding", "Funding reversal alerts", arg)
|
||||
elif cmd == "/kol":
|
||||
await _cmd_toggle(chat_id, "alert_kol_divergence", "KOL divergence alerts", arg)
|
||||
# ── numeric / range prefs ────────────────────────────────────
|
||||
elif cmd == "/conf":
|
||||
await _cmd_conf(chat_id, arg)
|
||||
elif cmd == "/quiet":
|
||||
await _cmd_quiet(chat_id, arg)
|
||||
else:
|
||||
if cmd.startswith("/"):
|
||||
await send_message(chat_id, "Unknown command. Send /help for the list.")
|
||||
except Exception:
|
||||
logger.exception("Telegram handler failed for chat=%s text=%r", chat_id, text[:80])
|
||||
|
||||
|
||||
async def run_bot_loop() -> None:
|
||||
"""Long-poll forever. Idempotent on offsets — Telegram serves the same
|
||||
update twice only if we don't acknowledge it. Sleep on errors to avoid
|
||||
hot-looping if the network is down."""
|
||||
token = settings.telegram_bot_token
|
||||
if not token:
|
||||
logger.info("Telegram bot loop not started — TELEGRAM_BOT_TOKEN empty.")
|
||||
return
|
||||
|
||||
logger.info("Telegram bot loop starting (long-poll mode).")
|
||||
offset: Optional[int] = None
|
||||
backoff = 1.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
url = TG_API.format(token=token, method="getUpdates")
|
||||
params: dict = {"timeout": 25}
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
async with httpx.AsyncClient(timeout=35) as client:
|
||||
r = await client.get(url, params=params)
|
||||
if r.status_code != 200:
|
||||
logger.warning("Telegram getUpdates HTTP %d: %s", r.status_code, r.text[:200])
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
continue
|
||||
backoff = 1.0
|
||||
data = r.json()
|
||||
for upd in data.get("result", []):
|
||||
offset = upd["update_id"] + 1
|
||||
msg = upd.get("message") or upd.get("edited_message")
|
||||
if msg:
|
||||
await _handle_message(msg)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telegram bot loop cancelled.")
|
||||
raise
|
||||
except httpx.ReadTimeout:
|
||||
# Normal — long-poll returned with no updates within timeout. Just retry.
|
||||
# (Previously caught by the bare Exception below and logged with an
|
||||
# empty message — "error: — sleeping" — which was opaque.)
|
||||
continue
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError) as exc:
|
||||
# Network-level error — expected on transient connectivity issues.
|
||||
# Compact log to avoid spamming when the network is down.
|
||||
logger.warning(
|
||||
"Telegram bot loop network error: %s (%s) — sleeping %.1fs",
|
||||
type(exc).__name__, exc, backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
except Exception:
|
||||
# Anything else is unexpected — log the full traceback so we
|
||||
# can actually diagnose it. Previously this silently swallowed
|
||||
# exceptions with no message body.
|
||||
logger.exception("Telegram bot loop unexpected error — sleeping %.1fs", backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
|
||||
# ── Admin helper ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def unbind_wallet(wallet: str) -> int:
|
||||
"""Detach a wallet from its Telegram binding (called by /api/telegram/unbind).
|
||||
|
||||
Sets wallet_address = NULL rather than deleting the row. The chat stays
|
||||
subscribed at the free tier — matches the UI's promise ("Your free
|
||||
subscription stays active in the bot"). User wanting a full disconnect
|
||||
sends /stop in the bot.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
r = await db.execute(
|
||||
update(TelegramBinding)
|
||||
.where(TelegramBinding.wallet_address == wallet.lower())
|
||||
.values(wallet_address=None)
|
||||
)
|
||||
await db.commit()
|
||||
return r.rowcount
|
||||
Reference in New Issue
Block a user