Files
Click/services/gql_chat_service.py
T
Yuriy Yuriev b02b7518cf add stiky
2026-05-29 21:47:40 +07:00

265 lines
9.3 KiB
Python

"""
Twitch чат через IRC over WebSocket (wss://irc-ws.chat.twitch.tv:443).
Замена raw TCP IRC — тот же протокол, WebSocket транспорт.
"""
import asyncio
import logging
import re
from typing import Optional, Callable, Awaitable, List
from datetime import datetime
from urllib.parse import urlparse
import aiohttp
from config.settings import settings
logger = logging.getLogger(__name__)
_IRC_WS_URL = "wss://irc-ws.chat.twitch.tv:443"
class TwitchGQLChatClient:
"""
Twitch IRC over WebSocket клиент.
Интерфейс совместим с TwitchIRCClient.
"""
def __init__(
self,
channel: str,
target_username: str,
irc_username: str = None,
irc_oauth: str = None,
):
self.channel = channel.lower()
self.target_username = target_username.lower()
self.irc_username = irc_username or settings.IRC_USERNAME
self.irc_oauth = irc_oauth or settings.IRC_OAUTH
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._session: Optional[aiohttp.ClientSession] = None
self._connected = False
self._url_pattern = re.compile(r'https?://[^\s<>"]+')
async def connect(self) -> bool:
try:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(
_IRC_WS_URL,
timeout=aiohttp.ClientTimeout(total=15),
)
await self._send(f"PASS {self.irc_oauth}")
await self._send(f"NICK {self.irc_username}")
await self._send("CAP REQ :twitch.tv/tags")
await self._send("CAP REQ :twitch.tv/commands")
await self._send(f"JOIN #{self.channel}")
await asyncio.sleep(2)
self._connected = True
logger.info(f"✅ IRC WS подключен к #{self.channel}")
return True
except Exception as e:
logger.error(f"❌ IRC WS ошибка подключения: {e}")
await self._cleanup()
return False
async def _send(self, message: str) -> None:
if self._ws and not self._ws.closed:
await self._ws.send_str(f"{message}\r\n")
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 _parse_message(self, line: str) -> Optional[dict]:
if not line or "PRIVMSG" not in line:
return None
try:
tags = {}
if line.startswith("@"):
tags_part = line.split(" ", 1)[0]
for tag in tags_part.lstrip("@").split(";"):
if "=" in tag:
k, v = tag.split("=", 1)
tags[k] = v
display_name = tags.get("display-name", "")
message_text = line.split(" :", 1)[1] if " :" in line else ""
if not message_text.strip():
return None
return {
"display_name": display_name or "unknown",
"message": message_text,
"tags": tags,
}
except Exception as e:
logger.error(f"Parse error: {e}")
return None
def _extract_urls(self, text: str) -> List[str]:
urls = []
for raw_url in self._url_pattern.findall(text):
try:
parsed = urlparse(raw_url)
clean = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
if parsed.query:
clean += f"?{parsed.query}"
urls.append(clean)
except Exception:
urls.append(raw_url.strip())
return urls
def _is_domain_allowed(self, url: str, allowed_domains: List[str]) -> bool:
if not allowed_domains:
return True
try:
domain = urlparse(url).netloc.lower()
return any(d.lower() in domain for d in allowed_domains)
except Exception:
return False
async def listen_for_messages(
self,
on_url_found: Callable[[str, str], Awaitable[None]],
duration: int,
allowed_domains: Optional[List[str]] = None,
is_active: Callable[[], bool] = None,
on_drain_fail: Callable[[], Awaitable[None]] = None,
) -> dict:
stats = {"links_found": 0, "messages": 0, "reconnects": 0}
last_message_time = datetime.now()
drain_errors = 0
if is_active and not is_active():
logger.info(f"⏸️ #{self.channel} ждём resume перед подключением...")
while is_active and not is_active():
await asyncio.sleep(2)
if not await self.connect():
raise ConnectionError(f"IRC WS connect failed for #{self.channel}")
# start_time после паузы и подключения — пауза не считается в длительность
start_time = datetime.now()
while duration == 0 or (datetime.now() - start_time).total_seconds() < duration:
if is_active and not is_active():
logger.info(f"⏸️ #{self.channel} пауза...")
await self._cleanup()
pause_begin = datetime.now()
while is_active and not is_active():
await asyncio.sleep(2)
# Исключаем время паузы из отсчёта длительности
start_time += datetime.now() - pause_begin
logger.info(f"▶️ #{self.channel} resume, переподключение...")
if not await self.connect():
drain_errors += 1
if drain_errors >= 3 and on_drain_fail:
await on_drain_fail()
return stats
else:
drain_errors = 0
stats["reconnects"] += 1
last_message_time = datetime.now()
continue
if not self._connected or self._ws is None or self._ws.closed:
await asyncio.sleep(5)
if not await self.connect():
drain_errors += 1
if drain_errors >= 3 and on_drain_fail:
await on_drain_fail()
return stats
else:
drain_errors = 0
stats["reconnects"] += 1
last_message_time = datetime.now()
continue
# Реконнект при отсутствии активности > 6 минут
if (datetime.now() - last_message_time).total_seconds() > 360:
logger.info("🔄 Нет активности 6 мин, переподключение...")
await self._cleanup()
await asyncio.sleep(1)
if not await self.connect():
drain_errors += 1
if drain_errors >= 3 and on_drain_fail:
await on_drain_fail()
return stats
else:
drain_errors = 0
stats["reconnects"] += 1
last_message_time = datetime.now()
continue
try:
raw = await asyncio.wait_for(self._ws.receive(), timeout=5.0)
except asyncio.CancelledError:
raise
except asyncio.TimeoutError:
continue
except Exception as e:
logger.warning(f"IRC WS 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
line = raw.data.strip()
if not line:
continue
if line.startswith("PING"):
await self._send("PONG :tmi.twitch.tv")
last_message_time = datetime.now()
logger.debug("🏓 PONG")
continue
last_message_time = datetime.now()
msg = self._parse_message(line)
if not msg:
continue
stats["messages"] += 1
logger.debug(f"IRC WS msg from {msg['display_name']}: {msg['message'][:50]}...")
for url in self._extract_urls(msg["message"]):
if self._is_domain_allowed(url, allowed_domains):
stats["links_found"] += 1
try:
await on_url_found(url, msg["display_name"])
except asyncio.CancelledError:
raise
except Exception:
pass
return stats
async def disconnect(self) -> None:
await self._cleanup()