ForcePilot/backend/package/yuxi/channels/adapters/twitch/helix.py
Kris 59cd13cf84 feat(twitch): 实现完整的Twitch聊天适配器模块
新增Twitch IRC协议相关的全套实现,包括:
1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重
2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理
3. API客户端:Helix API封装、认证提供者
4. 配置与部署:配置校验、设置向导
5. 辅助功能:配对管理、健康检查、目标解析等
2026-05-12 00:50:10 +08:00

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