""" 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, " "Macro Vibes bottom signal, funding extremes, KOL divergence. " "Plus a once-a-day 3-section brief.\n\n" "Just press /start — you're subscribed. No wallet needed.\n\n" "Real-time alerts:\n" " /trump on|off Trump · Truth Social posts\n" " /btc on|off Macro Vibes · bottom signal\n" " /funding on|off Macro Vibes · 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" "Daily brief:\n" " /digest send me a preview right now\n" " /digest on|off toggle the daily auto-send\n" " /digest_time HH set the UTC hour (default 12)\n\n" "Status & control:\n" " /status — show your preferences\n" " /stop — pause all alerts\n" " /test — send a sample alert\n\n" "Manage HL positions (Pro):\n" " /adopt hand an HL position to the bot\n" " /release stop managing (HL position stays open)\n\n" "Pro features (manage + auto-trade on Hyperliquid):\n" "Open the dashboard → SettingsConnect 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, # Daily digest defaults — on at 12 UTC (Asia evening / EU afternoon / # US morning; only single hour reasonable for all three zones). "digest_enabled": True, "digest_hour_utc": 12, } 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, "🔕 Real-time alerts paused. Send /start any time to re-enable.\n\n" "Your daily brief is unaffected — send /digest off " "to stop that separately.", ) 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() # Also fetch the subscription if the binding is wallet-linked, so we # can show auto_trade + paper_mode — the two most important trading # states that were previously invisible in /status. sub = None if b and b.wallet_address: sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == b.wallet_address) )).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" digest_state = ( f"🟢 ON @ {b.digest_hour_utc:02d}:00 UTC" if b.digest_enabled else "🔴 OFF" ) # Pro-only trading state block — only shown when wallet-linked. trading_block = "" if sub: auto = "🟢 ON" if sub.auto_trade else "🔴 OFF" mode = "📝 Paper" if sub.paper_mode else "💰 Live" cb_line = "" if sub.circuit_breaker_tripped_at: cb_line = f"\n🚨 Circuit breaker: tripped ({sub.circuit_breaker_reason or 'risk limit'})" else: cb_line = "\n✓ Circuit breaker: clear" trading_block = ( f"\n\n— Auto-Trader —\n" f"Auto-Trade: {auto}\n" f"Mode: {mode}{cb_line}" ) 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" f"Daily brief: {digest_state}" f"{trading_block}\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"/digest /digest_time — send /help for the full list.", ) async def _cmd_digest(chat_id: int, arg: str) -> None: """Three dispatches in one command: /digest → send a preview RIGHT NOW (manual fetch) /digest on|off → toggle daily auto-send Anything else → usage hint Time changes go through /digest_time HH (separate command — easier to parse and discover than overloading /digest with a numeric arg).""" arg_l = arg.strip().lower() if not arg_l: from app.services.telegram_digest import send_preview_for ok = await send_preview_for(chat_id) if not ok: await send_message(chat_id, "Couldn't render a preview right now. Send /start first if " "you haven't, otherwise try again in a minute.") return v = _parse_on_off(arg_l) if v is None: await send_message(chat_id, "Usage:\n" " /digest — preview right now\n" " /digest on or /digest off\n" " /digest_time 14 — set send hour (UTC)") return ok = await _set_pref(chat_id, "digest_enabled", 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} · daily brief") async def _cmd_digest_time(chat_id: int, arg: str) -> None: try: h = int(arg.strip()) if not 0 <= h <= 23: raise ValueError except ValueError: await send_message(chat_id, "Usage: /digest_time 14 (UTC hour, 0–23).\n" "Example: /digest_time 0 = midnight UTC.") return ok = await _set_pref(chat_id, "digest_hour_utc", h) if not ok: await send_message(chat_id, "No binding here. Send /start first.") return await send_message(chat_id, f"⏰ Daily brief now lands at {h:02d}:00 UTC.") async def _wallet_for_chat(chat_id: int) -> Optional[str]: """Return the bound wallet address for this chat_id, or None if the chat hasn't linked one yet (free tier).""" async with async_session() as db: b = (await db.execute( select(TelegramBinding).where(TelegramBinding.chat_id == chat_id) )).scalar_one_or_none() return b.wallet_address if (b and b.wallet_address) else None async def _cmd_adopt(chat_id: int, arg: str) -> None: """List the user's HL open positions and let them pick one to hand to the bot. Pro-only — needs a wallet bound (we read HL state via the subscription's API key). /adopt — show picker with inline keyboard /adopt BTC — skip the picker, jump straight to mode pick for BTC """ wallet = await _wallet_for_chat(chat_id) if not wallet: await send_message( chat_id, "⚠️ /adopt is Pro-only.\n" "It needs to read your Hyperliquid open positions, so the " "Telegram chat must be linked to a wallet first.\n\n" "Open the dashboard → Settings → Connect Telegram → paste the " "code with /start CODE.", ) return from app.services.adoption import list_hl_positions, AdoptionError try: positions = await list_hl_positions(wallet) except AdoptionError as exc: await send_message(chat_id, f"❌ {exc.message}") return if not positions: await send_message( chat_id, "🟦 You have no open positions on Hyperliquid right now.\n\n" "Open the trade on HL first, then come back and send /adopt.", ) return arg_u = arg.strip().upper() if arg_u: # Jump straight to mode picker for the requested asset. match = next((p for p in positions if p.asset == arg_u), None) if not match: await send_message( chat_id, f"❌ No open {arg_u} position on Hyperliquid. " f"Send /adopt with no args to see the list.", ) return await _send_mode_picker(chat_id, match) return # Build the asset picker. rows: list[list[dict]] = [] for p in positions: if p.already_adopted: label = f"✅ {p.asset} {p.side} ${p.size_usd:.0f} (already managed)" # Disabled-looking row — still tap-able so they get feedback. rows.append([{"text": label, "callback_data": f"adopt:dup:{p.asset}"}]) else: arrow = "🟢" if p.side == "long" else "🔴" label = (f"{arrow} {p.asset} {p.side} " f"${p.size_usd:.0f} @ {p.entry_price:.2f} · {p.leverage}x") rows.append([{"text": label, "callback_data": f"adopt:pick:{p.asset}"}]) rows.append([{"text": "✖ Cancel", "callback_data": "adopt:cancel"}]) await send_message( chat_id, "Pick a position to hand to the bot:\n\n" "Bot will then run the 5-rung stop ladder + de-risk + pyramid + " "peak-trail against it. You can release any time with " "/release ID.", reply_markup={"inline_keyboard": rows}, ) async def _send_mode_picker(chat_id: int, position) -> None: """After an asset is picked, prompt for standard vs aggressive mode.""" arrow = "🟢" if position.side == "long" else "🔴" text = ( f"{arrow} {position.asset} {position.side} " f"${position.size_usd:.0f} @ {position.entry_price:.2f} · " f"{position.leverage}x\n\n" "Pick the risk mode the bot should use:\n\n" "📈 Standard — conservative ladder, longer hold, slower pyramid. " "Designed to survive bull-cycle corrections.\n" "🚀 Aggressive — earlier + bigger pyramid adds, wider peak-trail. " "Bigger reward on a clean multi-x, harder shakeouts.\n" ) rows = [ [{"text": "📈 Standard", "callback_data": f"adopt:mode:{position.asset}:standard"}], [{"text": "🚀 Aggressive", "callback_data": f"adopt:mode:{position.asset}:aggressive"}], [{"text": "✖ Cancel", "callback_data": "adopt:cancel"}], ] await send_message(chat_id, text, reply_markup={"inline_keyboard": rows}) async def _cmd_release(chat_id: int, arg: str) -> None: """Hand a managed trade back to the user. /release — show picker with all currently managed trades /release 42 — release trade_id 42 directly """ wallet = await _wallet_for_chat(chat_id) if not wallet: await send_message( chat_id, "⚠️ /release is Pro-only — link a wallet first via " "Settings → Connect Telegram.") return from app.models import BotTrade async with async_session() as db: managed = (await db.execute( select(BotTrade).where( BotTrade.wallet_address == wallet, BotTrade.closed_at.is_(None), BotTrade.released_at.is_(None), ).order_by(BotTrade.opened_at.desc()) )).scalars().all() if not managed: await send_message( chat_id, "🟦 No positions currently under bot management. " "Nothing to release.", ) return arg_s = arg.strip() if arg_s: try: tid = int(arg_s) except ValueError: await send_message(chat_id, "Usage: /release 42 (trade id) — or just " "/release to pick from a list.") return target = next((t for t in managed if t.id == tid), None) if not target: await send_message(chat_id, f"❌ Trade {tid} isn't under bot management for this wallet.") return await _confirm_release(chat_id, target) return # Picker. rows = [] for t in managed: arrow = "🟢" if t.side == "long" else "🔴" label = (f"{arrow} #{t.id} {t.asset} {t.side} " f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}") rows.append([{"text": label, "callback_data": f"release:pick:{t.id}"}]) rows.append([{"text": "✖ Cancel", "callback_data": "release:cancel"}]) await send_message( chat_id, "Release a position from bot management:\n\n" "The HL position stays open — bot just stops driving stops / " "de-risk / pyramid on it.", reply_markup={"inline_keyboard": rows}, ) async def _confirm_release(chat_id: int, trade) -> None: arrow = "🟢" if trade.side == "long" else "🔴" text = ( f"{arrow} #{trade.id} {trade.asset} {trade.side} " f"${(trade.size_usd or 0):.0f} @ {trade.entry_price:.2f}\n\n" "Release this trade from bot management?\n" "The HL position will stay open — you take back manual control." ) rows = [ [{"text": "✅ Yes, release", "callback_data": f"release:yes:{trade.id}"}], [{"text": "✖ Cancel", "callback_data": "release:cancel"}], ] await send_message(chat_id, text, reply_markup={"inline_keyboard": rows}) 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) # ── daily brief ────────────────────────────────────────────── elif cmd == "/digest": await _cmd_digest(chat_id, arg) elif cmd in ("/digest_time", "/digesttime"): await _cmd_digest_time(chat_id, arg) # ── manage-only flow (System-2 reversal) ───────────────────── elif cmd == "/adopt": await _cmd_adopt(chat_id, arg) elif cmd == "/release": await _cmd_release(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 _handle_callback(cb: dict) -> None: """Route inline-button presses. Callback data is a tiny colon-delimited payload (max 64 bytes per Telegram). All branches MUST end with an answerCallbackQuery — otherwise the user's button spins forever.""" from app.services.telegram import answer_callback, edit_message from app.services.adoption import ( list_hl_positions, adopt_position, release_management, AdoptionError, ) cb_id = cb.get("id") data = cb.get("data") or "" msg = cb.get("message") or {} chat = msg.get("chat") or {} chat_id = chat.get("id") msg_id = msg.get("message_id") if not (cb_id and chat_id and msg_id): return parts = data.split(":") domain = parts[0] if parts else "" try: # ── ADOPT flow ────────────────────────────────────────────────── if domain == "adopt": sub = parts[1] if len(parts) > 1 else "" if sub == "cancel": await edit_message(chat_id, msg_id, "✖ Adopt cancelled.") await answer_callback(cb_id) return if sub == "dup": # Asset is already adopted — tell the user how to release, # and also edit the picker message so the UI doesn't stay stuck. asset_dup = parts[2] if len(parts) > 2 else "this position" await edit_message( chat_id, msg_id, f"✅ {asset_dup} is already under bot management.\n\n" f"To stop managing it and take back manual control:\n" f"/release — pick from list\n" f"or send /release <trade_id> directly." ) await answer_callback(cb_id, "Already managed — see message.", show_alert=False) return if sub == "pick" and len(parts) == 3: asset = parts[2] wallet = await _wallet_for_chat(chat_id) if not wallet: await answer_callback(cb_id, "Wallet not bound.", show_alert=True) return try: positions = await list_hl_positions(wallet) except AdoptionError as exc: await edit_message(chat_id, msg_id, f"❌ {exc.message}") await answer_callback(cb_id) return target = next((p for p in positions if p.asset == asset), None) if not target: await edit_message(chat_id, msg_id, f"❌ No open {asset} on HL anymore — refresh /adopt.") await answer_callback(cb_id) return # Replace the picker with the mode picker in place. arrow = "🟢" if target.side == "long" else "🔴" text = ( f"{arrow} {target.asset} {target.side} " f"${target.size_usd:.0f} @ {target.entry_price:.2f} · " f"{target.leverage}x\n\n" "Pick the risk mode:\n\n" "📈 Standard — conservative, designed to survive " "bull-cycle corrections.\n" "🚀 Aggressive — earlier + bigger pyramid adds, " "wider peak-trail. Higher reward, harder shakeouts.\n" ) rows = [ [{"text": "📈 Standard", "callback_data": f"adopt:mode:{asset}:standard"}], [{"text": "🚀 Aggressive", "callback_data": f"adopt:mode:{asset}:aggressive"}], [{"text": "✖ Cancel", "callback_data": "adopt:cancel"}], ] await edit_message(chat_id, msg_id, text, reply_markup={"inline_keyboard": rows}) await answer_callback(cb_id) return if sub == "mode" and len(parts) == 4: asset, mode = parts[2], parts[3] wallet = await _wallet_for_chat(chat_id) if not wallet: await answer_callback(cb_id, "Wallet not bound.", show_alert=True) return try: result = await adopt_position(wallet, asset, mode) except AdoptionError as exc: await edit_message(chat_id, msg_id, f"❌ {exc.message}") await answer_callback(cb_id, exc.code, show_alert=False) return # Render success summary — mention the de-risk + ladder so # the user understands what the bot will actually DO. derisk_summary = " → ".join( f"{thr:.0f}% close {int(frac * 100)}%" for thr, frac, _ in (result.derisk_ladder or []) ) ladder_summary = " → ".join( f"peak {trig:.0f}% lock {floor:+.0f}%" for trig, floor in (result.stop_ladder or []) ) text = ( f"✅ Adopted #{result.trade_id} · {result.asset} " f"{result.side} · {result.leverage}x · " f"{result.mode}\n\n" f"Entry: {result.entry_price:.2f} · " f"Size: ${result.size_usd:.0f}\n" f"Protective stop: -{result.protective_stop:.1f}% " f"(inside HL liquidation)\n\n" f"De-risk ladder (underwater):\n{derisk_summary}\n\n" f"Stop ratchet (in profit):\n{ladder_summary}\n\n" f"Release any time: /release {result.trade_id}" ) await edit_message(chat_id, msg_id, text) await answer_callback(cb_id, "Adopted ✓") return # ── RELEASE flow ──────────────────────────────────────────────── if domain == "release": sub = parts[1] if len(parts) > 1 else "" if sub == "cancel": await edit_message(chat_id, msg_id, "✖ Release cancelled.") await answer_callback(cb_id) return if sub == "pick" and len(parts) == 3: try: tid = int(parts[2]) except ValueError: await answer_callback(cb_id, "Bad trade id", show_alert=True) return # Refetch + render the confirm sheet in place. from app.models import BotTrade async with async_session() as db: t = (await db.execute( select(BotTrade).where(BotTrade.id == tid) )).scalar_one_or_none() if t is None or t.closed_at is not None or t.released_at is not None: await edit_message(chat_id, msg_id, f"❌ Trade {tid} no longer managed.") await answer_callback(cb_id) return arrow = "🟢" if t.side == "long" else "🔴" text = ( f"{arrow} #{t.id} {t.asset} {t.side} " f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}\n\n" "Release this trade from bot management?\n" "The HL position will stay open — you take back manual " "control." ) rows = [ [{"text": "✅ Yes, release", "callback_data": f"release:yes:{tid}"}], [{"text": "✖ Cancel", "callback_data": "release:cancel"}], ] await edit_message(chat_id, msg_id, text, reply_markup={"inline_keyboard": rows}) await answer_callback(cb_id) return if sub == "yes" and len(parts) == 3: try: tid = int(parts[2]) except ValueError: await answer_callback(cb_id, "Bad trade id", show_alert=True) return wallet = await _wallet_for_chat(chat_id) if not wallet: await answer_callback(cb_id, "Wallet not bound.", show_alert=True) return try: res = await release_management(wallet, tid) except AdoptionError as exc: await edit_message(chat_id, msg_id, f"❌ {exc.message}") await answer_callback(cb_id) return await edit_message(chat_id, msg_id, f"✅ Released #{res['trade_id']} · {res['asset']}\n\n" "Bot is no longer managing this position. " "HL state is untouched — you have manual control.\n\n" "Re-adopt any time with /adopt.") await answer_callback(cb_id, "Released ✓") return # Unknown callback — just ack so the button doesn't spin. await answer_callback(cb_id) except Exception: logger.exception("Telegram callback handler failed for data=%r", data) try: await answer_callback(cb_id, "Internal error", show_alert=True) except Exception: pass 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).") # ── Startup drain ──────────────────────────────────────────────────── # On restart, Telegram may replay up to 24h of unacknowledged updates. # The most dangerous replays are stale /adopt callback_query items that # would re-fire an adoption the user already dealt with. Fix: pull all # pending updates with timeout=0 (non-blocking), advance offset past # them without processing, then start the real loop fresh. try: drain_url = TG_API.format(token=token, method="getUpdates") from app.services.http_client import get_client r = await get_client().get(drain_url, params={"timeout": 0, "limit": 100}, timeout=10) if r.status_code == 200: pending = r.json().get("result", []) if pending: drain_offset = pending[-1]["update_id"] + 1 # ACK by sending offset back — Telegram won't re-deliver these. await get_client().get(drain_url, params={"timeout": 0, "offset": drain_offset}, timeout=10) logger.info( "Startup drain: skipped %d stale update(s), offset now %d", len(pending), drain_offset, ) except Exception as exc: logger.warning("Startup drain failed (non-fatal): %s", exc) 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 from app.services.http_client import get_client r = await get_client().get(url, params=params, timeout=35) 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) continue cb = upd.get("callback_query") if cb: # Inline-keyboard button press (/adopt and /release # picker flows). Acknowledged inside the handler so the # button never spins forever on the user's client. await _handle_callback(cb) 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