""" 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] def format_trump_mention(post: Post) -> str: """Public-channel-only alert for Trump posts that are crypto-relevant but don't carry a clear directional signal (signal=hold, relevant=True). These posts can still move markets (e.g. "I support crypto development") so we surface them as low-urgency monitoring alerts. Never sent per-user to avoid subscriber spam; only broadcast to the public channel. """ body = (post.text or "").strip() if len(body) > 300: body = body[:300].rstrip() + "…" fe = (settings.frontend_url or "").rstrip("/") link = f'\n\n→ view on TrumpAlpha' if fe else "" msg = ( f"📢 Trump mentioned crypto\n" f"Trump · Truth Social — no clear trade signal\n\n" f"{body}" f"{link}" ) return msg[:MAX_LEN] def format_public_post(post: Post) -> str: """Render a sanitised version of a Post for the public broadcast channel. Intentionally omits: - expected_move_pct / invalidation_price (execution-sensitive data) - /adopt CTA (requires a private bot session) - Any wallet address or subscriber-only details Keeps: signal direction, asset, confidence tier, source label, post excerpt (≤ 280 chars), and a link back to the relevant dashboard section. """ 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 # Confidence tier label instead of raw number (less intimidating publicly) if conf >= 80: conf_label = "HIGH" elif conf >= 60: conf_label = "MED" elif conf > 0: conf_label = "LOW" else: conf_label = "—" head = f"{emoji} {asset} · {sig} · conf {conf_label}" sub = f"{src}" body = (post.text or "").strip() if len(body) > 280: body = body[:280].rstrip() + "…" # KOL divergence: strip wallet address from the text if present # (the raw text shouldn't contain one, but belt-and-suspenders) if post.source == "kol_divergence" and post.ai_reasoning: reason = post.ai_reasoning.strip() if len(reason) > 200: reason = reason[:200].rstrip() + "…" body = reason fe = (settings.frontend_url or "").rstrip("/") link = "" if fe: 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→ view on TrumpAlpha' msg = f"{head}\n{sub}\n\n{body}{link}" return msg[:MAX_LEN] # ── Low-level HTTP ──────────────────────────────────────────────────────── async def send_message(chat_id: int | str, 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). `chat_id` may be an integer user/group ID or a string channel username such as ``"@trumpalpha"`` (with or without the leading @). `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=%s 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=%s: %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. Two distinct paths: PATH A — actionable signal (signal=buy/short): • Per-subscriber fan-out filtered by alert preference + min_confidence • Public channel broadcast (format_public_post) PATH B — crypto mention (source=truth, relevant=True, signal=hold): • Public channel ONLY (format_trump_mention) — no per-subscriber push to avoid noise for individual users who subscribed for trade signals. • These posts are crypto-relevant but lack a clear directional call. They can still move markets so we surface them as monitoring alerts. """ 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 is_actionable = post.signal in ("buy", "short") is_trump_mention = ( post.source == "truth" and bool(post.relevant) and post.signal == "hold" ) if not is_actionable and not is_trump_mention: # Non-relevant noise — nothing to send. return # ── PATH B: crypto mention (public channel only) ────────────────── if is_trump_mention and not is_actionable: channel_id = settings.telegram_public_channel_id if channel_id: mention_text = format_trump_mention(post) ok = await send_message(channel_id, mention_text) if ok: logger.info("Telegram mention alert: post=%d sent to %s", post_id, channel_id) else: logger.warning("Telegram mention alert: post=%d send FAILED to %s", post_id, channel_id) return # ── PATH A: actionable signal (buy/short) ───────────────────────── 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() 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)) # ── Public channel broadcast ────────────────────────────────────── channel_id = settings.telegram_public_channel_id if channel_id: public_text = format_public_post(post) ok = await send_message(channel_id, public_text) if ok: logger.info("Telegram public channel: post=%d sent to %s", post_id, channel_id) else: logger.warning("Telegram public channel: post=%d send FAILED to %s", post_id, channel_id) 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.")