""" 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/btc", "funding_reversal": "/en/btc", "kol_divergence": "/en/kol", }.get(post.source, "/en") link = f'\n\n→ open in dashboard' msg = f"{head}\n{sub}\n\n{body}{extra}{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) -> bool: """Single HTTP POST to Telegram Bot API. Returns True on 200, False on any failure (caller decides whether to bump the failure counter).""" token = settings.telegram_bot_token if not token: return False url = TG_API.format(token=token, method="sendMessage") try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(url, json={ "chat_id": chat_id, "text": text, "parse_mode": parse_mode, "disable_web_page_preview": disable_preview, }) 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 # ── 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.")