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

307 lines
11 KiB
Python

"""
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
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:
from app.services.http_client import get_client
resp = await get_client().post(TWEET_URL, json=payload, headers=headers, timeout=10)
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)