"""
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 = (
"👋 Trump Alpha bot\n\n"
"Push alerts for high-conviction crypto signals — Trump posts, BTC bottom "
"reversals, funding extremes, KOL divergence.\n\n"
"Just press /start — you're subscribed. No wallet needed.\n\n"
"Configure:\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"
"Status & control:\n"
" /status — show your preferences\n"
" /stop — pause all alerts\n"
" /test — send a sample alert\n\n"
"Pro features (auto-trade on Hyperliquid):\n"
"Open the dashboard → Settings → Connect Telegram 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,
"🎉 You're subscribed!\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,
"❌ Invalid or expired code.\n\n"
"The 6-char code only comes from the dashboard's "
"Settings → Connect Telegram 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,
"⚠️ This wallet isn't subscribed yet.\n\n"
"Open Settings on the dashboard, click Start trading, "
"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"✅ Wallet linked: {short}\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: {label.lower().split()[0]} on or "
f"{label.lower().split()[0]} off")
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: /conf 70 (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 {n}.")
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: /quiet 23 7 (UTC, e.g. mute 23:00–07:00) "
"or /quiet off.")
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: Pro · wallet {short}"
else:
tier_line = "Tier: Free · 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"📡 Status\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,
"🟢 BTC · BUY · conf 88\n"
"Trump · Truth Social\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