e2c00937b5
Bug 1: /status didn't show auto_trade or paper_mode — the two most important trading states for Pro users. Added a trading block that queries the Subscription row (only when wallet-linked) and shows: Auto-Trade: ON/OFF, Mode: Paper/Live, Circuit breaker: clear/tripped. Bug 2: adopt:dup callback (tapping an 'already managed' position in the /adopt picker) answered the spinner but left the picker message stuck with no guidance. Now edits the message in place: explains the position is already managed and shows /release commands. Button no longer results in a visually broken UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1076 lines
44 KiB
Python
1076 lines
44 KiB
Python
"""
|
||
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, "
|
||
"Macro Vibes bottom signal, funding extremes, KOL divergence. "
|
||
"Plus a once-a-day 3-section brief.\n\n"
|
||
"<b>Just press /start</b> — you're subscribed. No wallet needed.\n\n"
|
||
"<b>Real-time alerts:</b>\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"
|
||
"<b>Daily brief:</b>\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"
|
||
"<b>Status & control:</b>\n"
|
||
" /status — show your preferences\n"
|
||
" /stop — pause all alerts\n"
|
||
" /test — send a sample alert\n\n"
|
||
"<b>Manage HL positions (Pro):</b>\n"
|
||
" /adopt hand an HL position to the bot\n"
|
||
" /release stop managing (HL position stays open)\n\n"
|
||
"<b>Pro features</b> (manage + 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,
|
||
# 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,
|
||
"🎉 <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,
|
||
"🔕 Real-time alerts paused. Send /start any time to re-enable.\n\n"
|
||
"Your daily brief is unaffected — send <code>/digest off</code> "
|
||
"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: <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"
|
||
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<b>— Auto-Trader —</b>\n"
|
||
f"Auto-Trade: {auto}\n"
|
||
f"Mode: {mode}{cb_line}"
|
||
)
|
||
|
||
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"
|
||
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"
|
||
" <code>/digest</code> — preview right now\n"
|
||
" <code>/digest on</code> or <code>/digest off</code>\n"
|
||
" <code>/digest_time 14</code> — 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: <code>/digest_time 14</code> (UTC hour, 0–23).\n"
|
||
"Example: <code>/digest_time 0</code> = 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 <b>{h:02d}:00 UTC</b>.")
|
||
|
||
|
||
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,
|
||
"⚠️ <b>/adopt is Pro-only.</b>\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 <code>/start CODE</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,
|
||
"<b>Pick a position to hand to the bot:</b>\n\n"
|
||
"Bot will then run the 5-rung stop ladder + de-risk + pyramid + "
|
||
"peak-trail against it. You can release any time with "
|
||
"<code>/release ID</code>.",
|
||
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"<b>{arrow} {position.asset} {position.side} "
|
||
f"${position.size_usd:.0f} @ {position.entry_price:.2f} · "
|
||
f"{position.leverage}x</b>\n\n"
|
||
"Pick the risk mode the bot should use:\n\n"
|
||
"<b>📈 Standard</b> — conservative ladder, longer hold, slower pyramid. "
|
||
"Designed to survive bull-cycle corrections.\n"
|
||
"<b>🚀 Aggressive</b> — 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: <code>/release 42</code> (trade id) — or just "
|
||
"<code>/release</code> 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,
|
||
"<b>Release a position from bot management:</b>\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"<b>{arrow} #{trade.id} {trade.asset} {trade.side} "
|
||
f"${(trade.size_usd or 0):.0f} @ {trade.entry_price:.2f}</b>\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,
|
||
"🟢 <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)
|
||
# ── 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"✅ <b>{asset_dup}</b> is already under bot management.\n\n"
|
||
f"To stop managing it and take back manual control:\n"
|
||
f"<code>/release</code> — pick from list\n"
|
||
f"or send <code>/release <trade_id></code> 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"<b>{arrow} {target.asset} {target.side} "
|
||
f"${target.size_usd:.0f} @ {target.entry_price:.2f} · "
|
||
f"{target.leverage}x</b>\n\n"
|
||
"Pick the risk mode:\n\n"
|
||
"<b>📈 Standard</b> — conservative, designed to survive "
|
||
"bull-cycle corrections.\n"
|
||
"<b>🚀 Aggressive</b> — 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"✅ <b>Adopted #{result.trade_id} · {result.asset} "
|
||
f"{result.side} · {result.leverage}x · "
|
||
f"{result.mode}</b>\n\n"
|
||
f"Entry: <code>{result.entry_price:.2f}</code> · "
|
||
f"Size: <code>${result.size_usd:.0f}</code>\n"
|
||
f"Protective stop: <b>-{result.protective_stop:.1f}%</b> "
|
||
f"(inside HL liquidation)\n\n"
|
||
f"<i>De-risk ladder (underwater):</i>\n{derisk_summary}\n\n"
|
||
f"<i>Stop ratchet (in profit):</i>\n{ladder_summary}\n\n"
|
||
f"Release any time: <code>/release {result.trade_id}</code>"
|
||
)
|
||
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"<b>{arrow} #{t.id} {t.asset} {t.side} "
|
||
f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}</b>\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"✅ <b>Released #{res['trade_id']} · {res['asset']}</b>\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).")
|
||
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)
|
||
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
|