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

511 lines
20 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 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 html
import logging
from datetime import datetime, timezone
from typing import Optional
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. start<until → mute inside [start, until).
start>until → window wraps midnight (e.g. 23..7 → mute 23, 06)."""
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.
# asset comes from post.target_asset (scanner-written) — escape defensively;
# sig/conf/src are controlled enums/ints/labels.
head = f"{emoji} <b>{html.escape(asset)} · {sig}</b> · conf <b>{conf}</b>"
sub = f"<i>{src}</i>"
# Truncate the RAW text first, THEN escape — escaping first could let a
# 5-char entity (&amp;) get sliced mid-sequence by the length cap.
body = (post.text or "").strip()
if len(body) > 600:
body = body[:600].rstrip() + ""
# parse_mode=HTML: Trump posts routinely contain & < > ("Law & Order").
# Without escaping, Telegram rejects the whole message (400 can't parse
# entities) and every subscriber silently misses the alert.
body = html.escape(body)
# 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: <b>{post.expected_move_pct:+.1f}%</b>"
if post.invalidation_price is not None:
extra += f"\n🛑 invalidation @ <code>{post.invalidation_price:g}</code>"
# 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<a href="{fe}{path}">→ open in dashboard</a>'
# 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\n<b>Want bot to manage your trade?</b>\n"
"1. Open the position on Hyperliquid (your size / leverage).\n"
"2. Reply <code>/adopt</code> 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 <code>/release</code>."
)
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() + ""
body = html.escape(body) # see format_post — escape user text for HTML mode
fe = (settings.frontend_url or "").rstrip("/")
link = f'\n\n<a href="{fe}/en/trump">→ view on TrumpAlpha</a>' if fe else ""
msg = (
f"📢 <b>Trump mentioned crypto</b>\n"
f"<i>Trump · Truth Social — no clear trade signal</i>\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} <b>{html.escape(asset)} · {sig}</b> · conf <b>{conf_label}</b>"
sub = f"<i>{src}</i>"
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
# Escape AFTER choosing text-vs-reason — both are user/AI content going
# into an HTML-parse-mode message. (See format_post.)
body = html.escape(body)
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<a href="{fe}{path}">→ view on TrumpAlpha</a>'
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:
from app.services.http_client import get_client
r = await get_client().post(url, json=payload, timeout=10)
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:
from app.services.http_client import get_client
r = await get_client().post(url, json=payload, timeout=10)
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:
from app.services.http_client import get_client
await get_client().post(url, json=payload, timeout=10)
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)
# Strong references to in-flight dispatch tasks. asyncio.create_task() only
# keeps a WEAK reference, so a fire-and-forget task can be garbage-collected
# mid-await — "Task was destroyed but it is pending!" — silently dropping a
# subscriber fan-out. We hold the task until it completes, then auto-discard.
_dispatch_tasks: set[asyncio.Task] = set()
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:
task = asyncio.create_task(_dispatch(post.id))
_dispatch_tasks.add(task)
task.add_done_callback(_dispatch_tasks.discard)
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,
"✅ <b>Trump Alpha connected.</b>\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.")