""" X (Twitter) auto-poster — viral "live prediction" tweets for actionable signals. Mirrors the design of telegram.py: fire-and-forget, fully degradable. If X creds are missing or `x_enabled` is False, every entry point becomes a no-op (dry-run logs the tweet text but sends nothing). A signal MUST never be blocked by an X API failure. notify_x_signal(post) ← call right after notify_signal(post) in the ingest path Flow per actionable signal: 1. Immediately post the PREDICTION tweet (entry / TP / SL, hard time window). 2. Schedule a FOLLOW-UP tweet `x_followup_minutes` later that reports the actual move (price_impact_m15) — the "told you" brag that drives the link. Auth: OAuth 1.0a User Context, signed by hand with stdlib hmac/hashlib (no extra dependency — httpx is already vendored). Posting uses POST /2/tweets. Tone: deliberately provocative / "黑红" — invites ratios, taunts CT, no soft CTA. All templates are hard-capped at 280 chars (no X Premium = 280 limit). """ from __future__ import annotations import asyncio import base64 import hashlib import hmac import logging import secrets import time import urllib.parse from datetime import datetime, timezone from typing import Optional import httpx from sqlalchemy import select from app.config import settings from app.database import AsyncSessionLocal as async_session from app.models import Post logger = logging.getLogger(__name__) TWEET_URL = "https://api.twitter.com/2/tweets" MAX_TWEET = 280 # In-memory daily cap counter. Single-process by design (see main.py leader # guard), so a plain dict is safe. Resets when the UTC date rolls over. _sent_today = 0 _sent_date: Optional[str] = None # ── credential / enablement gate ────────────────────────────────────────── def _creds_ok() -> bool: return bool( settings.x_api_key and settings.x_api_secret and settings.x_access_token and settings.x_access_secret ) def _under_daily_cap() -> bool: """True if we still have budget today. Increments are done by _record_sent.""" global _sent_today, _sent_date today = datetime.now(timezone.utc).strftime("%Y-%m-%d") if today != _sent_date: _sent_date = today _sent_today = 0 return _sent_today < settings.x_daily_cap def _record_sent() -> None: global _sent_today _sent_today += 1 # ── OAuth 1.0a signing ───────────────────────────────────────────────────── def _percent(s: str) -> str: # RFC 3986 — OAuth requires ~ unescaped and everything else strict. return urllib.parse.quote(str(s), safe="~") def _oauth_header(method: str, url: str) -> str: """Build the OAuth 1.0a Authorization header for a JSON-body request. For POST /2/tweets the body is JSON (not form-encoded), so per the OAuth spec the body params are NOT part of the signature base string — only the oauth_* params are. This is the documented X v2 behaviour. """ oauth = { "oauth_consumer_key": settings.x_api_key, "oauth_nonce": secrets.token_hex(16), "oauth_signature_method": "HMAC-SHA1", "oauth_timestamp": str(int(time.time())), "oauth_token": settings.x_access_token, "oauth_version": "1.0", } # Signature base string param_str = "&".join( f"{_percent(k)}={_percent(v)}" for k, v in sorted(oauth.items()) ) base = "&".join([method.upper(), _percent(url), _percent(param_str)]) signing_key = f"{_percent(settings.x_api_secret)}&{_percent(settings.x_access_secret)}" digest = hmac.new( signing_key.encode(), base.encode(), hashlib.sha1 ).digest() oauth["oauth_signature"] = base64.b64encode(digest).decode() header = "OAuth " + ", ".join( f'{_percent(k)}="{_percent(v)}"' for k, v in sorted(oauth.items()) ) return header async def _post_tweet(text: str, reply_to: Optional[str] = None) -> Optional[str]: """POST a single tweet. Returns the new tweet id, or None on failure / dry-run. `reply_to` chains the follow-up under the prediction tweet (a thread), which reads better and keeps the brag attached to the original call. """ text = text[:MAX_TWEET] if not settings.x_enabled: logger.info("[x_poster] DRY-RUN (x_enabled=False), would tweet:\n%s", text) return None if not _creds_ok(): logger.warning("[x_poster] missing X creds — skipping tweet") return None if not _under_daily_cap(): logger.warning("[x_poster] daily cap (%d) reached — skipping tweet", settings.x_daily_cap) return None payload: dict = {"text": text} if reply_to: payload["reply"] = {"in_reply_to_tweet_id": reply_to} headers = { "Authorization": _oauth_header("POST", TWEET_URL), "Content-Type": "application/json", } try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(TWEET_URL, json=payload, headers=headers) if resp.status_code in (200, 201): _record_sent() tid = resp.json().get("data", {}).get("id") logger.info("[x_poster] tweeted id=%s (%d/%d today)", tid, _sent_today, settings.x_daily_cap) return tid logger.warning("[x_poster] tweet failed %s: %s", resp.status_code, resp.text[:300]) return None except Exception as exc: # noqa: BLE001 — never let X break ingestion logger.warning("[x_poster] tweet exception: %s", exc) return None # ── win-rate helper (for the brag in the follow-up) ──────────────────────── async def _recent_hit_rate(db) -> tuple[int, Optional[float]]: """(count, hit_rate_pct) over recent scored directional posts. A 'hit' = the realised 15-min move agreed with the call direction (buy → up, short → down). Cheap, audit-friendly, and honest: it reads the same price_impact_m15 we already record. Returns (n, None) if too few. """ rows = ( await db.execute( select(Post.signal, Post.price_impact_m15) .where( Post.signal.in_(("buy", "short")), Post.price_impact_m15.isnot(None), ) .order_by(Post.id.desc()) .limit(200) ) ).all() n = len(rows) if n < 5: return n, None hits = 0 for sig, move in rows: if move is None: continue if (sig == "buy" and move > 0) or (sig == "short" and move < 0): hits += 1 return n, round(100.0 * hits / n, 1) # ── tweet templates (≤280, 黑红 tone) ─────────────────────────────────────── def _dir_word(signal: Optional[str]) -> str: return "LONG" if signal == "buy" else "SHORT" if signal == "short" else "WATCH" def format_prediction(post: Post) -> str: """The instant 'Trump just posted, here's my call' tweet.""" asset = (post.target_asset or "BTC").upper() d = _dir_word(post.signal) entry = post.price_at_post mins = settings.x_followup_minutes entry_line = f"Entry ~${entry:,.0f}\n" if entry else "" # Hard, falsifiable, time-boxed → invites screenshots and ratios. text = ( f"Trump just posted. 🟠\n\n" f"{asset}: {d} — moves within {mins} min.\n" f"{entry_line}" f"\nScreenshot this. Wrong? Ratio me.\n" f"Right? You already know. ⏰" ) return text[:MAX_TWEET] def format_followup(post: Post, move_pct: Optional[float], n: int, hit_rate: Optional[float]) -> str: """The '15 min later, told you' brag. Drives the only link we ever post.""" asset = (post.target_asset or "BTC").upper() mins = settings.x_followup_minutes link = (settings.x_link_url or settings.frontend_url or "").rstrip("/") if move_pct is None: result = f"{asset}: still cooking. Check the board 👇" else: arrow = "✅" if ( (post.signal == "buy" and move_pct > 0) or (post.signal == "short" and move_pct < 0) ) else "🤡" result = f"{asset}: {move_pct:+.1f}% {arrow}" rate_line = "" if hit_rate is not None: rate_line = f"\n{hit_rate:.0f}% hit rate over {n} calls. No CT badge needed." tail = f"\n{link}" if link else "" text = f"{mins} min later.\n\n{result}{rate_line}\n\nBookmarked yet?{tail}" return text[:MAX_TWEET] # ── dispatch ─────────────────────────────────────────────────────────────── async def _x_dispatch(post_id: int) -> None: """Post the prediction now, then schedule the follow-up brag.""" async with async_session() as db: post = await db.get(Post, post_id) if not post: return # Only post Trump-sourced actionable calls — that's the viral hook. # (BTC/funding scanner signals are slow-burn; they don't fit the # "Trump just posted, watch this" format.) if post.source != "truth" or post.signal not in ("buy", "short"): return if post.ai_confidence and post.ai_confidence < settings.x_min_confidence: logger.info("[x_poster] post=%d conf %s < %d — skipping", post_id, post.ai_confidence, settings.x_min_confidence) return pred_text = format_prediction(post) tweet_id = await _post_tweet(pred_text) # Schedule the follow-up. Fire-and-forget asyncio task (single process by # design). A restart within the window loses the pending follow-up — that's # acceptable for a marketing post; the signal itself is already safe. delay = max(0, settings.x_followup_minutes) * 60 asyncio.create_task(_followup_later(post_id, tweet_id, delay)) async def _followup_later(post_id: int, reply_to: Optional[str], delay: int) -> None: try: await asyncio.sleep(delay) async with async_session() as db: post = await db.get(Post, post_id) if not post: return move = post.price_impact_m15 n, rate = await _recent_hit_rate(db) text = format_followup(post, move, n, rate) await _post_tweet(text, reply_to=reply_to) except asyncio.CancelledError: # shutdown — drop quietly raise except Exception as exc: # noqa: BLE001 logger.warning("[x_poster] follow-up failed post=%d: %s", post_id, exc) def notify_x_signal(post: Post) -> None: """Fire-and-forget entry point. Mirrors telegram.notify_signal. Safe to call unconditionally — degrades to a no-op when X is disabled/unconfigured.""" if not _creds_ok() and not settings.x_enabled: # Neither configured nor in dry-run intent → silent skip. return if not post or not post.id: return try: asyncio.create_task(_x_dispatch(post.id)) except RuntimeError: logger.warning("[x_poster] no running event loop, skipping post=%s", post.id)