""" Telegram push alerts — send + signal dispatcher. This module is fire-and-forget by design. `notify_signal()` returns immediately to the caller (signal ingestion path) and dispatches in a background task. A DB failure or Telegram API error MUST NOT block a signal from being saved. The bot token is loaded from `settings.telegram_bot_token`. If empty, every function in this module degrades into a no-op (and logs once at module load). notify_signal(post) ← called from /api/signals/ingest send_test_message(wallet) ← called from /api/telegram/test send_message(chat_id, text) ← low-level escape hatch Source → user-toggle mapping: truth → alert_trump btc_bottom_reversal → alert_btc_bottom funding_reversal → alert_funding kol_divergence (future) → alert_kol_divergence """ from __future__ import annotations import asyncio import logging from datetime import datetime, 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 Post, TelegramBinding logger = logging.getLogger(__name__) TG_API = "https://api.telegram.org/bot{token}/{method}" # Telegram caps a single message at 4096 chars; we render way below this. MAX_LEN = 3500 # ── Source → preference column mapping ──────────────────────────────────── def _pref_column_for_source(source: str) -> Optional[str]: """Which user-toggle column gates this source. None → unknown source, don't send.""" if source == "truth": return "alert_trump" if source == "btc_bottom_reversal": return "alert_btc_bottom" if source == "funding_reversal": return "alert_funding" if source == "kol_divergence": return "alert_kol_divergence" return None def _is_in_mute_window(now_hour: int, mute_from: Optional[int], mute_until: Optional[int]) -> bool: """Both null → never mute. startuntil → window wraps midnight (e.g. 23..7 → mute 23, 0–6).""" if mute_from is None or mute_until is None: return False if mute_from == mute_until: return False if mute_from < mute_until: return mute_from <= now_hour < mute_until # wraps midnight return now_hour >= mute_from or now_hour < mute_until # ── Formatting ──────────────────────────────────────────────────────────── def _signal_emoji(post: Post) -> str: if post.signal == "buy": return "🟢" if post.signal == "short": return "🔴" return "⚪" def _source_label(source: str) -> str: return { "truth": "Trump · Truth Social", "btc_bottom_reversal": "BTC · Macro Bottom", "funding_reversal": "BTC · Funding Reversal", "kol_divergence": "KOL · Talks vs Trades", }.get(source, source) def format_post(post: Post) -> str: """Render a Post into a single Telegram message (HTML parse mode). Keep it scannable: heading, one-line verdict, the underlying text, link.""" emoji = _signal_emoji(post) src = _source_label(post.source) sig = (post.signal or "noise").upper() asset = post.target_asset or "?" conf = post.ai_confidence or 0 # Heading: emoji + asset + direction + confidence head = f"{emoji} {asset} · {sig} · conf {conf}" sub = f"{src}" body = (post.text or "").strip() if len(body) > 600: body = body[:600].rstrip() + "…" # Move size hint if present (BTC bottom & funding emit expected_move_pct) extra = "" if post.expected_move_pct is not None: extra = f"\n📐 expected move: {post.expected_move_pct:+.1f}%" if post.invalidation_price is not None: extra += f"\n🛑 invalidation @ {post.invalidation_price:g}" # Deep-link back to the dashboard. Frontend URL comes from settings. fe = (settings.frontend_url or "").rstrip("/") link = "" if fe: # Use the section that matches the source path = { "truth": "/en/trump", "btc_bottom_reversal": "/en/macro", "funding_reversal": "/en/macro", "kol_divergence": "/en/kol", }.get(post.source, "/en") link = f'\n\n→ open in dashboard' # System-2 (Macro Vibes bottom / funding reversal) is MANUAL-OPEN now: # we no longer auto-trade these on behalf of subscribers. The signal is # day-K level so an entry delay doesn't matter — but the alert needs # to make the next user action obvious. cta = "" if post.source in ("btc_bottom_reversal", "funding_reversal"): cta = ( "\n\nWant bot to manage your trade?\n" "1. Open the position on Hyperliquid (your size / leverage).\n" "2. Reply /adopt here to hand it to the bot.\n" "Bot will run the 5-rung stop ladder, de-risk, pyramid, peak-trail. " "You can take it back any time with /release." ) msg = f"{head}\n{sub}\n\n{body}{extra}{cta}{link}" return msg[:MAX_LEN] # ── Low-level HTTP ──────────────────────────────────────────────────────── async def send_message(chat_id: int, text: str, *, parse_mode: str = "HTML", disable_preview: bool = True, reply_markup: Optional[dict] = None) -> bool: """Single HTTP POST to Telegram Bot API. Returns True on 200, False on any failure (caller decides whether to bump the failure counter). `reply_markup` is the standard Bot-API inline keyboard / reply keyboard payload, e.g. {"inline_keyboard": [ [{"text": "Yes", "callback_data": "x:y"}], [{"text": "No", "callback_data": "x:n"}], ]} Callback_data is limited to 64 bytes by Telegram; keep payloads short. """ token = settings.telegram_bot_token if not token: return False url = TG_API.format(token=token, method="sendMessage") payload: dict = { "chat_id": chat_id, "text": text, "parse_mode": parse_mode, "disable_web_page_preview": disable_preview, } if reply_markup is not None: payload["reply_markup"] = reply_markup try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(url, json=payload) if r.status_code != 200: logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s", chat_id, r.status_code, r.text[:200]) return False return True except Exception as exc: logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc) return False async def edit_message(chat_id: int, message_id: int, text: str, *, parse_mode: str = "HTML", reply_markup: Optional[dict] = None) -> bool: """Edit an existing message (typically to swap out an inline keyboard after the user pressed a button). Returns True on 200.""" token = settings.telegram_bot_token if not token: return False url = TG_API.format(token=token, method="editMessageText") payload: dict = { "chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode, } if reply_markup is not None: payload["reply_markup"] = reply_markup try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(url, json=payload) if r.status_code != 200: # Telegram returns 400 on "message is not modified" — harmless. if "message is not modified" not in r.text: logger.warning("Telegram editMessageText failed chat=%d msg=%d " "status=%d body=%s", chat_id, message_id, r.status_code, r.text[:200]) return False return True except Exception as exc: logger.warning("Telegram editMessageText exception: %s", exc) return False async def answer_callback(callback_query_id: str, text: str = "", show_alert: bool = False) -> bool: """Acknowledge an inline-button press. MUST be called within 30s of the callback firing or the button stays in 'loading' state on the user's client. Empty text → silent ack.""" token = settings.telegram_bot_token if not token: return False url = TG_API.format(token=token, method="answerCallbackQuery") payload = {"callback_query_id": callback_query_id} if text: payload["text"] = text payload["show_alert"] = show_alert try: async with httpx.AsyncClient(timeout=10) as client: await client.post(url, json=payload) return True except Exception as exc: logger.debug("Telegram answerCallback exception: %s", exc) return False # ── Dispatcher ──────────────────────────────────────────────────────────── async def _dispatch(post_id: int) -> None: """Fan-out a single Post to every eligible subscriber. Always runs in its own DB session so signal ingestion's session is unaffected.""" if not settings.telegram_bot_token: return async with async_session() as db: post = await db.get(Post, post_id) if not post: logger.warning("Telegram dispatch: post id=%d not found", post_id) return # Only fan out for actionable signals (not NOISE / null) if not post.signal or post.signal not in ("buy", "short"): return pref_col = _pref_column_for_source(post.source) if pref_col is None: logger.debug("Telegram: unknown source %r — not fanning out", post.source) return # Build the query: alerts_enabled + the relevant per-source flag. # min_confidence applies to every source — scanner-emitted signals # carry their own confidence in the Post.ai_confidence column. col = getattr(TelegramBinding, pref_col) base_filters = [ TelegramBinding.alerts_enabled.is_(True), col.is_(True), ] # Only apply confidence gate when the post has a real score (> 0). # Scanner-generated signals (funding, BTC) always carry a score, but # a Truth-Social post might be dispatched before Claude scores it (score=0). # In that edge case we let it through so no alert is silently swallowed. if post.ai_confidence and post.ai_confidence > 0: base_filters.append(TelegramBinding.min_confidence <= post.ai_confidence) q = select(TelegramBinding).where(*base_filters) bindings = (await db.execute(q)).scalars().all() if not bindings: return text = format_post(post) now = datetime.now(timezone.utc) hour = now.hour sent = 0 for b in bindings: if _is_in_mute_window(hour, b.mute_from_hour, b.mute_until_hour): continue ok = await send_message(b.chat_id, text) # Update audit counters per binding. Single UPDATE per row keeps # us out of trouble if one user blocks the bot. if ok: await db.execute( update(TelegramBinding) .where(TelegramBinding.id == b.id) .values( last_alert_at=now, total_alerts_sent=TelegramBinding.total_alerts_sent + 1, ) ) sent += 1 else: await db.execute( update(TelegramBinding) .where(TelegramBinding.id == b.id) .values( total_alerts_failed=TelegramBinding.total_alerts_failed + 1, ) ) await db.commit() logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d", post_id, post.source, sent, len(bindings)) def notify_signal(post: Post) -> None: """Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop and returns immediately. Safe to call from any async context — falls back to a no-op if telegram is disabled or no event loop is running. We pass post.id rather than the post object because the caller's DB session might close before our background task runs.""" if not settings.telegram_bot_token: return if not post or not post.id: return try: asyncio.create_task(_dispatch(post.id)) except RuntimeError: # No running loop — extremely unusual in our FastAPI context. logger.warning("notify_signal: no running event loop, skipping post=%s", post.id) async def send_test_message(wallet: str) -> bool: """Triggered from the Settings UI to verify the binding works end-to-end.""" if not settings.telegram_bot_token: return False async with async_session() as db: b = (await db.execute( select(TelegramBinding).where(TelegramBinding.wallet_address == wallet.lower()) )).scalar_one_or_none() if not b: return False return await send_message( b.chat_id, "✅ Trump Alpha connected.\n\n" "You'll get push alerts here when signals fire. " "Use /trump /btc /funding /kol /conf /quiet in this chat to tune " "which sources and thresholds apply to you.", ) if not settings.telegram_bot_token: logger.info("Telegram bot token not set — push alerts disabled.")