Spaces:
Runtime error
Runtime error
File size: 8,662 Bytes
bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 bdd1640 34b0226 fa6e4a3 34b0226 bdd1640 34b0226 bdd1640 34b0226 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | """X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains; images and a video go on the
first post (v2 media upload: one call for an image, initialize / append / finalize / status for a video)."""
import base64
import hashlib
import secrets
import time
from urllib.parse import urlencode
import httpx
from sqlalchemy.orm import Session
from app.config import settings
from app.models import SocialAccount
from app.services.crypto import decrypt
from app.services.social import SocialError, expired, save_tokens, scope_set, token
AUTH_URL = "https://x.com/i/oauth2/authorize"
TOKEN_URL = "https://api.x.com/2/oauth2/token"
API = "https://api.x.com/2"
SCOPES = "tweet.read tweet.write users.read offline.access media.write"
CHUNK = 4 * 1024 * 1024 # video upload segments
PROCESSING_WAIT = 600 # seconds X may take to process a video before we give up
def redirect_uri() -> str:
return f"{settings.api_url.rstrip('/')}/api/social/x/callback"
def configured() -> bool:
return bool(settings.x_enabled and settings.x_client_id and settings.x_client_secret)
def pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(64)[:96]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
def authorize_url(state: str, challenge: str) -> str:
return AUTH_URL + "?" + urlencode({
"response_type": "code", "client_id": settings.x_client_id, "redirect_uri": redirect_uri(),
"scope": SCOPES, "state": state, "code_challenge": challenge, "code_challenge_method": "S256",
})
def _token_request(data: dict) -> dict:
resp = httpx.post(TOKEN_URL, data={**data, "client_id": settings.x_client_id},
auth=(settings.x_client_id, settings.x_client_secret), timeout=20)
if resp.status_code >= 400:
raise SocialError(f"X token request failed: {resp.text[:300]}", permanent=resp.status_code in (400, 401))
return resp.json()
def exchange_code(code: str, verifier: str) -> dict:
tokens = _token_request({"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri(),
"code_verifier": verifier})
me = httpx.get(f"{API}/users/me", params={"user.fields": "profile_image_url"},
headers={"Authorization": f"Bearer {tokens['access_token']}"}, timeout=20)
me.raise_for_status()
user = me.json()["data"]
return {**tokens, "external_id": user["id"], "handle": user["username"],
"avatar_url": user.get("profile_image_url")}
def refresh(db: Session, account: SocialAccount) -> None:
"""Swap the refresh token for new tokens (X rotates the refresh token too). A refused refresh means the
connection is gone: reconnect."""
if not account.refresh_token:
raise SocialError("The X connection expired. Reconnect X.", reconnect=True)
try:
tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
except SocialError as exc:
if exc.permanent:
raise SocialError("The X connection expired. Reconnect X.", reconnect=True) from exc
raise
save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
def _access(db: Session, account: SocialAccount) -> str:
if expired(account):
refresh(db, account)
return token(account)
def check_alive(db: Session, account: SocialAccount) -> None:
"""One cheap call: raises SocialError(reconnect=True) when X no longer accepts this connection."""
resp = _call("GET", f"{API}/users/me", headers={"Authorization": f"Bearer {_access(db, account)}"}, timeout=20)
if resp.status_code == 401:
raise SocialError("X no longer accepts this connection. Reconnect X.", reconnect=True)
def _call(method: str, url: str, **kw) -> httpx.Response:
"""Every request to X goes through here (tests replace it)."""
return httpx.request(method, url, timeout=kw.pop("timeout", 60), **kw)
def can_post_media(account: SocialAccount) -> bool:
"""Accounts connected before media.write was asked for have to reconnect to post images or videos."""
return "media.write" in scope_set(account.scopes)
def _media_error(resp: httpx.Response) -> SocialError:
if resp.status_code in (401, 403):
return SocialError("X refused the upload. Reconnect X to post images and videos.", reconnect=True)
return SocialError(f"X media upload {resp.status_code}: {resp.text[:300]}",
permanent=400 <= resp.status_code < 500 and resp.status_code != 429)
def _upload_image(headers: dict, m) -> str:
resp = _call("POST", f"{API}/media/upload", headers=headers, data={"media_category": "tweet_image"},
files={"media": (m.filename, m.read(), m.content_type)})
if resp.status_code >= 400:
raise _media_error(resp)
return resp.json()["data"]["id"]
def _upload_video(headers: dict, m) -> str:
resp = _call("POST", f"{API}/media/upload/initialize", headers=headers,
json={"media_type": m.content_type, "total_bytes": m.size, "media_category": "tweet_video"})
if resp.status_code >= 400:
raise _media_error(resp)
media_id = resp.json()["data"]["id"]
for i, start in enumerate(range(0, m.size, CHUNK)):
resp = _call("POST", f"{API}/media/upload/{media_id}/append", headers=headers, data={"segment_index": i},
files={"media": (m.filename, m.read(start, start + CHUNK), "application/octet-stream")})
if resp.status_code >= 400:
raise _media_error(resp)
resp = _call("POST", f"{API}/media/upload/{media_id}/finalize", headers=headers)
if resp.status_code >= 400:
raise _media_error(resp)
info = (resp.json().get("data") or {}).get("processing_info")
waited = 0
while info and info.get("state") in ("pending", "in_progress"):
if waited >= PROCESSING_WAIT:
raise SocialError("X took too long to process the video.")
pause = int(info.get("check_after_secs") or 5)
time.sleep(pause)
waited += pause
resp = _call("GET", f"{API}/media/upload", headers=headers,
params={"command": "STATUS", "media_id": media_id})
if resp.status_code >= 400:
raise _media_error(resp)
info = (resp.json().get("data") or {}).get("processing_info")
if info and info.get("state") == "failed":
reason = (info.get("error") or {}).get("message") or "X could not process the video"
raise SocialError(f"{reason}. X takes videos up to 2:20.", permanent=True)
return media_id
def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
headers = {"Authorization": f"Bearer {_access(db, account)}"}
media_ids: list[str] = []
if media:
if not can_post_media(account):
raise SocialError("Reconnect X to post images and videos.", permanent=True)
media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
first_id, reply_to = None, None
for text in posts:
body: dict = {"text": text} if text else {} # X takes a post with media and no text, but not an empty text
if reply_to:
body["reply"] = {"in_reply_to_tweet_id": reply_to}
elif media_ids:
body["media"] = {"media_ids": media_ids} # the images or video go on the first post
resp = _call("POST", f"{API}/tweets", json=body, headers=headers, timeout=20)
if resp.status_code == 401:
raise SocialError("X rejected the connection. Reconnect X.", reconnect=True)
if resp.status_code >= 400:
raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
reply_to = resp.json()["data"]["id"]
first_id = first_id or reply_to
return first_id, f"https://x.com/{account.handle}/status/{first_id}"
def metrics(db: Session, account: SocialAccount, external_id: str) -> dict:
resp = httpx.get(f"{API}/tweets", params={"ids": external_id, "tweet.fields": "public_metrics"},
headers={"Authorization": f"Bearer {_access(db, account)}"}, timeout=20)
if resp.status_code >= 400:
return {}
data = (resp.json().get("data") or [{}])[0].get("public_metrics") or {}
return {"likes": data.get("like_count", 0), "reposts": data.get("retweet_count", 0),
"replies": data.get("reply_count", 0), "impressions": data.get("impression_count", 0)}
|