This commit is contained in:
Yuriy Yuriev
2026-05-26 18:01:07 +07:00
parent 795b2e928c
commit 493c67cb1d
+92 -153
View File
@@ -1,13 +1,11 @@
""" """
GraphQL WebSocket сервис для мониторинга Twitch чата. GraphQL HTTP polling сервис для мониторинга Twitch чата.
Замена IRC — не требует OAuth токена.
""" """
import asyncio import asyncio
import json
import logging import logging
import re import re
from typing import Optional, Callable, Awaitable, List from typing import Optional, Callable, Awaitable, List, Set
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -15,24 +13,30 @@ import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_GQL_WS_URL = "wss://gql.twitch.tv/gql" _GQL_URL = "https://gql.twitch.tv/gql"
_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" _HEADERS = {
"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"Content-Type": "application/json",
}
_POLL_INTERVAL = 2.0 # секунд между запросами
_CHAT_SUBSCRIPTION = """\ _QUERY = """\
subscription ChatMessages($channelLogin: String!) { query ChannelChatMessages($login: String!, $after: ID) {
chatEvents(input: { channelLogin: $channelLogin }) { user(login: $login) {
... on ChatMessageEvent { chatRoom {
message { chatMessages(first: 50, after: $after) {
pageInfo {
endCursor
hasNextPage
}
nodes {
id
sender { sender {
displayName displayName
login login
} }
content { content {
text text
fragments {
... on TextFragment {
text
}
} }
} }
} }
@@ -43,77 +47,20 @@ subscription ChatMessages($channelLogin: String!) {
class TwitchGQLChatClient: class TwitchGQLChatClient:
""" """
Twitch чат клиент через GQL WebSocket. Twitch чат клиент через GQL HTTP polling.
Интерфейс совместим с TwitchIRCClient. Интерфейс совместим с TwitchIRCClient.
""" """
def __init__(self, channel: str, target_username: str): def __init__(self, channel: str, target_username: str):
self.channel = channel.lower() self.channel = channel.lower()
self.target_username = target_username.lower() self.target_username = target_username.lower()
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._session: Optional[aiohttp.ClientSession] = None
self._connected = False
self._url_pattern = re.compile(r'https?://[^\s<>"]+') self._url_pattern = re.compile(r'https?://[^\s<>"]+')
self._seen_ids: Set[str] = set()
self._cursor: Optional[str] = None
async def connect(self) -> bool: async def connect(self) -> bool:
try:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(
_GQL_WS_URL,
protocols=["graphql-ws"],
headers={"Client-ID": _CLIENT_ID},
timeout=aiohttp.ClientTimeout(total=15),
)
await self._ws.send_str(json.dumps({
"type": "connection_init",
"payload": {
"Authorization": "undefined",
"Client-ID": _CLIENT_ID,
}
}))
ack = await asyncio.wait_for(self._ws.receive(), timeout=10)
msg = json.loads(ack.data)
if msg.get("type") != "connection_ack":
logger.error(f"GQL WS: unexpected init response: {msg}")
await self._cleanup()
return False
await self._ws.send_str(json.dumps({
"type": "start",
"id": "chat",
"payload": {
"query": _CHAT_SUBSCRIPTION,
"variables": {"channelLogin": self.channel},
"operationName": "ChatMessages",
}
}))
self._connected = True
logger.info(f"✅ GQL подключен к #{self.channel}")
return True return True
except Exception as e:
logger.error(f"❌ GQL ошибка подключения: {e}")
await self._cleanup()
return False
async def _cleanup(self) -> None:
self._connected = False
try:
if self._ws and not self._ws.closed:
await self._ws.close()
except Exception:
pass
try:
if self._session and not self._session.closed:
await self._session.close()
except Exception:
pass
self._ws = None
self._session = None
def _extract_urls(self, text: str) -> List[str]: def _extract_urls(self, text: str) -> List[str]:
urls = [] urls = []
for raw_url in self._url_pattern.findall(text): for raw_url in self._url_pattern.findall(text):
@@ -136,6 +83,46 @@ class TwitchGQLChatClient:
except Exception: except Exception:
return False return False
async def _fetch_messages(self, session: aiohttp.ClientSession) -> List[dict]:
payload = [{
"operationName": "ChannelChatMessages",
"variables": {"login": self.channel, "after": self._cursor},
"query": _QUERY,
}]
try:
async with session.post(
_GQL_URL,
json=payload,
headers=_HEADERS,
timeout=aiohttp.ClientTimeout(total=8),
) as resp:
data = await resp.json()
result = data[0] if isinstance(data, list) else data
errors = result.get("errors")
if errors:
logger.error(f"GQL schema errors: {errors}")
return []
chat_data = (
result.get("data", {})
.get("user", {})
.get("chatRoom", {})
.get("chatMessages", {})
)
if not chat_data:
return []
end_cursor = chat_data.get("pageInfo", {}).get("endCursor")
if end_cursor:
self._cursor = end_cursor
return chat_data.get("nodes", [])
except Exception as e:
logger.warning(f"GQL fetch error: {e}")
return []
async def listen_for_messages( async def listen_for_messages(
self, self,
on_url_found: Callable[[str, str], Awaitable[None]], on_url_found: Callable[[str, str], Awaitable[None]],
@@ -146,106 +133,53 @@ class TwitchGQLChatClient:
) -> dict: ) -> dict:
stats = {"links_found": 0, "messages": 0, "reconnects": 0} stats = {"links_found": 0, "messages": 0, "reconnects": 0}
start_time = datetime.now() start_time = datetime.now()
reconnect_errors = 0 fail_streak = 0
if is_active and not is_active(): if is_active and not is_active():
logger.info(f"⏸️ #{self.channel} ждём resume перед подключением...") logger.info(f"⏸️ #{self.channel} ждём resume...")
while is_active and not is_active(): while is_active and not is_active():
await asyncio.sleep(2) await asyncio.sleep(2)
if not await self.connect(): logger.info(f"🚀 GQL chat polling: #{self.channel}")
raise ConnectionError(f"GQL connect failed for #{self.channel}")
async with aiohttp.ClientSession() as session:
while duration == 0 or (datetime.now() - start_time).seconds < duration: while duration == 0 or (datetime.now() - start_time).seconds < duration:
if is_active and not is_active(): if is_active and not is_active():
logger.info(f"⏸️ #{self.channel} пауза...") logger.info(f"⏸️ #{self.channel} пауза...")
await self._cleanup()
while is_active and not is_active(): while is_active and not is_active():
await asyncio.sleep(2) await asyncio.sleep(2)
logger.info(f"▶️ #{self.channel} resume, переподключение...") logger.info(f"▶️ #{self.channel} resume")
if not await self.connect():
reconnect_errors += 1 messages = await self._fetch_messages(session)
if reconnect_errors >= 3 and on_drain_fail:
if messages is not None and len(messages) == 0 and self._cursor is None:
# Первый запрос — cursor ещё не установлен, это норма
fail_streak = 0
elif not messages:
fail_streak += 1
if fail_streak >= 10 and on_drain_fail:
await on_drain_fail() await on_drain_fail()
return stats return stats
else: else:
reconnect_errors = 0 fail_streak = 0
stats["reconnects"] += 1
continue
if not self._connected or self._ws is None or self._ws.closed: for msg in messages:
await asyncio.sleep(5) msg_id = msg.get("id")
if not await self.connect(): if not msg_id or msg_id in self._seen_ids:
reconnect_errors += 1
if reconnect_errors >= 3 and on_drain_fail:
await on_drain_fail()
return stats
else:
reconnect_errors = 0
stats["reconnects"] += 1
continue continue
self._seen_ids.add(msg_id)
if len(self._seen_ids) > 10000:
self._seen_ids = set(list(self._seen_ids)[-5000:])
try: sender = msg.get("sender", {})
raw = await asyncio.wait_for(self._ws.receive(), timeout=30.0)
except asyncio.CancelledError:
raise
except asyncio.TimeoutError:
continue
except Exception as e:
logger.warning(f"GQL receive error: {e}")
self._connected = False
continue
if raw.type in (
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
logger.warning(f"WS закрыт: {raw.type}")
self._connected = False
continue
if raw.type != aiohttp.WSMsgType.TEXT:
continue
try:
msg = json.loads(raw.data)
except Exception:
continue
msg_type = msg.get("type")
if msg_type == "ka": # keep-alive от Twitch
continue
if msg_type == "connection_error":
logger.error(f"GQL connection error: {msg}")
self._connected = False
continue
if msg_type != "data":
continue
payload = msg.get("payload", {})
data = payload.get("data", {})
chat_event = data.get("chatEvents")
if not chat_event:
continue
message_obj = chat_event.get("message", {})
if not message_obj:
continue
sender = message_obj.get("sender", {})
display_name = sender.get("displayName", "unknown") display_name = sender.get("displayName", "unknown")
content = message_obj.get("content", {}) text = (msg.get("content") or {}).get("text", "")
text = content.get("text", "")
if not text.strip(): if not text.strip():
continue continue
stats["messages"] += 1 stats["messages"] += 1
logger.debug(f"GQL message from {display_name}: {text[:50]}...") logger.debug(f"GQL msg from {display_name}: {text[:50]}...")
for url in self._extract_urls(text): for url in self._extract_urls(text):
if self._is_domain_allowed(url, allowed_domains): if self._is_domain_allowed(url, allowed_domains):
@@ -257,7 +191,12 @@ class TwitchGQLChatClient:
except Exception: except Exception:
pass pass
try:
await asyncio.sleep(_POLL_INTERVAL)
except asyncio.CancelledError:
raise
return stats return stats
async def disconnect(self) -> None: async def disconnect(self) -> None:
await self._cleanup() pass