新增Twitch IRC协议相关的全套实现,包括: 1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重 2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理 3. API客户端:Helix API封装、认证提供者 4. 配置与部署:配置校验、设置向导 5. 辅助功能:配对管理、健康检查、目标解析等
193 lines
7.3 KiB
Python
193 lines
7.3 KiB
Python
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
|