Files
trumpsignal-backend/app/services/telegram_bot.py
T

1100 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 &amp; 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> (0100). 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:0007: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 023.")
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, 023).\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 &lt;trade_id&gt;</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).")
# ── 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