fd2e61dc48
When Trump posts an actionable buy/short signal, instantly tweet the call (entry/direction, hard time window, "ratio me" hook), then post a result follow-up X_FOLLOWUP_MINUTES later reporting the realised 15-min move plus a recent hit-rate brag with the site link. - New app/services/x_poster.py: OAuth 1.0a signed (stdlib hmac, no new dep, httpx reused). Fire-and-forget, fully degradable — no-op unless X creds + x_enabled. Dry-run logs tweet text. In-memory UTC-daily cap. 280-char hard cap on all templates (no Premium). - Gated to source=truth buy/short above x_min_confidence; scanner signals excluded (don't fit the "Trump just posted" format). - Hooked next to notify_signal in /signals/ingest + both Trump pollers (dedup via unique external_id → single tweet per post). - config.py + .env.example: X_API_KEY/SECRET, X_ACCESS_TOKEN/SECRET, X_ENABLED, X_MIN_CONFIDENCE, X_DAILY_CAP, X_FOLLOWUP_MINUTES, X_LINK_URL. 72 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
11 KiB
Python
308 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
|
|
|
|
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)
|