ForcePilot/backend/package/yuxi/channels/adapters/twitch/helix.py
Kris 18d1ea2aac refactor(twitch): 重构Twitch适配器,新增Helix API支持与功能优化
本次提交对Twitch适配器进行了全面升级与优化:
1.  修复UTF8截断逻辑,避免越界访问
2.  重构群聊策略配置,标准化mention相关规则
3.  新增消息缓存管理器,支持通过消息ID查询已发送消息
4.  更新配置schema,新增prefer_helix_send开关和deprecated策略自动转换
5.  新增CLEARMSG和ROOMSTATE IRC消息解析,补充事件订阅支持
6.  优化令牌刷新逻辑,增加重试机制与退避策略
7.  新增Helix API聊天消息发送、删除和公告功能
8.  扩展事件订阅类型,新增直播状态、频道更新等系统事件
9.  新增reply、delete_message、announcement等动作支持,完善操作能力
10. 重构流式发送逻辑,新增进度指示器和配置项
11. 优化重连策略,增加指数退避与计数重置
2026-05-13 16:16:02 +08:00

279 lines
10 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:
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
except (TimeoutError, aiohttp.ClientError) as e:
if attempt == self.MAX_RETRIES - 1:
logger.error(f"Helix {method} {url} connection failed after {self.MAX_RETRIES} retries: {e}")
return None
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
async def send_chat_message(
self,
broadcaster_id: str,
sender_id: str,
message: str,
reply_parent_msg_id: str | None = None,
) -> dict[str, Any] | None:
url = f"{self.BASE_URL}/chat/messages"
body: dict[str, Any] = {
"broadcaster_id": broadcaster_id,
"sender_id": sender_id,
"message": message,
}
if reply_parent_msg_id:
body["reply_parent_msg_id"] = reply_parent_msg_id
resp = await self._request_with_backoff(
"POST",
url,
headers={**self._headers(), "Content-Type": "application/json"},
json=body,
)
if resp is None:
return None
try:
if resp.status == 200:
data = await resp.json()
return data.get("data", [{}])[0] if data.get("data") else None
logger.error(f"Helix send_chat_message failed: {resp.status} {await resp.text()}")
return None
except aiohttp.ClientError as e:
logger.error(f"Helix send_chat_message connection error: {e}")
return None
async def delete_chat_message(
self,
broadcaster_id: str,
moderator_id: str,
message_id: str,
) -> bool:
url = f"{self.BASE_URL}/chat/messages"
params = {
"broadcaster_id": broadcaster_id,
"moderator_id": moderator_id,
"message_id": message_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"Helix delete_chat_message error: {e}")
return False
async def send_chat_announcement(
self,
broadcaster_id: str,
moderator_id: str,
message: str,
color: str = "primary",
) -> bool:
url = f"{self.BASE_URL}/chat/announcements"
valid_colors = {"blue", "green", "orange", "purple", "primary"}
if color not in valid_colors:
color = "primary"
body = {
"broadcaster_id": broadcaster_id,
"moderator_id": moderator_id,
"message": message,
"color": color,
}
resp = await self._request_with_backoff(
"POST",
url,
headers={**self._headers(), "Content-Type": "application/json"},
json=body,
)
if resp is None:
return False
try:
return resp.status in (200, 204)
except aiohttp.ClientError as e:
logger.error(f"Helix send_chat_announcement error: {e}")
return False