""" Twitch чат через IRC over WebSocket (wss://irc-ws.chat.twitch.tv:443). Замена raw TCP IRC — тот же протокол, WebSocket транспорт. """ import asyncio import logging import random 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() # Anonymous Twitch login (justinfanNNNNN) is shared across all clients by # default, so parallel monitors on the same nick get kicked by Twitch # (one connection per nick) — randomize it unless a real login is configured. if irc_username or settings.IRC_USERNAME != "justinfan12345": self.irc_username = irc_username or settings.IRC_USERNAME else: self.irc_username = f"justinfan{random.randint(10000, 99999)}" 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 else: logger.info( f"🚫 Домен не разрешён: {url} (allowed_domains={allowed_domains})" ) return stats async def disconnect(self) -> None: await self._cleanup()