本次提交对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. 优化重连策略,增加指数退避与计数重置
279 lines
10 KiB
Python
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
|