from __future__ import annotations import asyncio from typing import Any import aiohttp from yuxi.utils.logging_config import logger class HelixClient: """Twitch Helix API 客户端""" BASE_URL = "https://api.twitch.tv/helix" AUTH_URL = "https://id.twitch.tv/oauth2/token" MAX_RETRIES = 3 BASE_BACKOFF = 1.0 MAX_BACKOFF = 10.0 RETRY_STATUSES = frozenset({401, 429, 500, 502, 503, 504}) def __init__(self, client_id: str, access_token: str): self._client_id = client_id self._access_token = access_token self._session: aiohttp.ClientSession | None = None async def start(self) -> None: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession() async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() self._session = None @property def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: raise RuntimeError("HelixClient session not started, call start() first") return self._session def _headers(self) -> dict[str, str]: return { "Authorization": f"Bearer {self._access_token}", "Client-Id": self._client_id, } async def _request_with_backoff(self, method: str, url: str, **kwargs) -> aiohttp.ClientResponse | None: last_status: int | None = None for attempt in range(self.MAX_RETRIES): try: async with self._ensure_session.request(method, url, **kwargs) as resp: if resp.status not in self.RETRY_STATUSES or attempt == self.MAX_RETRIES - 1: return resp last_status = resp.status except (TimeoutError, aiohttp.ClientError) as e: if attempt == self.MAX_RETRIES - 1: raise logger.warning(f"Helix {method} {url} attempt {attempt + 1} failed: {e}") delay = min(self.BASE_BACKOFF * (2**attempt), self.MAX_BACKOFF) logger.info(f"Helix {method} {url} retrying in {delay:.1f}s (attempt {attempt + 2}/{self.MAX_RETRIES})") await asyncio.sleep(delay) return None async def _get(self, path: str, **params: str) -> dict[str, Any] | None: url = f"{self.BASE_URL}{path}" resp = await self._request_with_backoff("GET", url, headers=self._headers(), params=params) if resp is None: return None try: if resp.status == 200: return await resp.json() if resp.status == 401: logger.warning(f"Helix GET {path} returned 401 after {self.MAX_RETRIES} retries") return None if resp.status == 404: return None logger.error(f"Helix GET {path} failed: {resp.status} {await resp.text()}") return None except aiohttp.ClientError as e: logger.error(f"Helix GET {path} connection error: {e}") return None async def validate_token(self) -> dict[str, Any] | None: data = await self._get("/users") if data: users = data.get("data", []) return users[0] if users else None return None async def get_user_by_name(self, username: str) -> dict[str, Any] | None: data = await self._get("/users", login=username) if data: users = data.get("data", []) return users[0] if users else None return None async def get_user_by_id(self, user_id: str) -> dict[str, Any] | None: data = await self._get("/users", id=user_id) if data: users = data.get("data", []) return users[0] if users else None return None async def get_channel_info(self, broadcaster_id: str) -> dict[str, Any] | None: data = await self._get("/channels", broadcaster_id=broadcaster_id) if data: channels = data.get("data", []) return channels[0] if channels else None return None async def get_chat_badges(self, broadcaster_id: str) -> dict[str, Any] | None: return await self._get("/chat/badges", broadcaster_id=broadcaster_id) async def get_global_chat_badges(self) -> dict[str, Any] | None: return await self._get("/chat/badges/global") async def create_eventsub_subscription(self, payload: dict[str, Any]) -> str | None: url = f"{self.BASE_URL}/eventsub/subscriptions" resp = await self._request_with_backoff("POST", url, headers=self._headers(), json=payload) if resp is None: return None try: if resp.status in (200, 202): data = await resp.json() subs = data.get("data", []) if subs: return subs[0].get("id") return None if resp.status == 409: return None if resp.status == 429: logger.warning("EventSub subscription rate limited") return None body = await resp.text() logger.error(f"EventSub subscription failed ({resp.status}): {body}") return None except aiohttp.ClientError as e: logger.error(f"EventSub subscription connection error: {e}") return None async def get_app_access_token(self, client_secret: str) -> str | None: params = { "client_id": self._client_id, "client_secret": client_secret, "grant_type": "client_credentials", } resp = await self._request_with_backoff("POST", self.AUTH_URL, json=params) if resp is None: return None try: if resp.status == 200: data = await resp.json() return data.get("access_token") logger.error(f"App access token request failed: {resp.status} {await resp.text()}") return None except aiohttp.ClientError as e: logger.error(f"App access token connection error: {e}") return None async def refresh_user_token(self, client_secret: str, refresh_token: str) -> dict[str, Any] | None: params = { "client_id": self._client_id, "client_secret": client_secret, "grant_type": "refresh_token", "refresh_token": refresh_token, } resp = await self._request_with_backoff("POST", self.AUTH_URL, json=params) if resp is None: return None try: if resp.status == 200: return await resp.json() logger.error(f"Token refresh failed: {resp.status} {await resp.text()}") return None except aiohttp.ClientError as e: logger.error(f"Token refresh connection error: {e}") return None async def delete_eventsub_subscription(self, subscription_id: str) -> bool: url = f"{self.BASE_URL}/eventsub/subscriptions" params = {"id": subscription_id} resp = await self._request_with_backoff("DELETE", url, headers=self._headers(), params=params) if resp is None: return False try: return resp.status in (200, 204) except aiohttp.ClientError as e: logger.error(f"EventSub delete subscription error: {e}") return False