""" IRC сервис для мониторинга Twitch чата. """ import asyncio import random import re import logging from typing import Optional, Callable, Awaitable, List from datetime import datetime from urllib.parse import urlparse from config.settings import settings logger = logging.getLogger(__name__) class TwitchIRCClient: """ Twitch IRC клиент для мониторинга чата. """ 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._reader: Optional[asyncio.StreamReader] = None self._writer: Optional[asyncio.StreamWriter] = None self._connected = False self._url_pattern = re.compile(r'https?://[^\s<>"]+') async def connect(self) -> bool: """Подключение к IRC серверу.""" try: import ssl ssl_context = ssl.create_default_context() self._reader, self._writer = await asyncio.wait_for( asyncio.open_connection( settings.IRC_SERVER, settings.IRC_PORT, ssl=ssl_context ), timeout=15.0 ) 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"✅ Подключен к #{self.channel}") return True except Exception as e: logger.error(f"❌ Ошибка подключения: {e}") return False async def _send(self, message: str) -> None: """Отправка сообщения.""" if self._writer: self._writer.write(f"{message}\r\n".encode()) await self._writer.drain() async def _read_line(self) -> Optional[str]: """Чтение строки с обработкой PING.""" if not self._reader: return None try: line = await self._reader.readline() if not line: logger.warning("Empty line - connection closed") self._connected = False return None decoded = line.decode('utf-8', errors='ignore').strip() # PING от Twitch — отвечаем СРАЗУ и возвращаем строку (обновит last_message_time) if decoded.startswith('PING'): self._writer.write(b"PONG :tmi.twitch.tv\r\n") try: await self._writer.drain() except (ConnectionResetError, BrokenPipeError, OSError) as e: logger.warning(f"Failed to send PONG: {e}") logger.debug("🏓 PONG :tmi.twitch.tv") return decoded # возвращаем чтобы основной цикл обновил last_message_time return decoded except Exception as e: logger.error(f"Read error: {e}") self._connected = False return None def _parse_message(self, line: str) -> Optional[dict]: if not line or 'PRIVMSG' not in line: return None try: # Парсим display-name из тегов 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', '') # Извлекаем сообщение if ' :' in line: message_text = line.split(' :', 1)[1] else: message_text = '' # Пропускаем пустые сообщения 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]: """Извлечение URL из текста.""" 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 # Если задача стартует на паузе — ждём resume до подключения к IRC if is_active and not is_active(): logger.info(f"⏸️ #{self.channel} waiting for resume before connecting...") while is_active and not is_active(): await asyncio.sleep(2) logger.info(f"▶️ #{self.channel} resumed, connecting to IRC") if not await self.connect(): raise ConnectionError(f"IRC 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} paused, waiting for resume...") await self.disconnect() 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} resumed, reconnecting") await self.connect() last_message_time = datetime.now() continue # Нет соединения — ждём перед повтором if not self._connected: await asyncio.sleep(5) await self.connect() last_message_time = datetime.now() continue now = datetime.now() # === Реконнект если нет активности > 6 минут (Twitch PING каждые ~5 мин) === if (now - last_message_time).total_seconds() > 360: logger.info("🔄 No activity 5min, reconnecting...") try: await self.disconnect() except Exception: pass await asyncio.sleep(1) connected = await self.connect() if not connected: 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: line = await asyncio.wait_for(self._read_line(), timeout=5.0) except asyncio.CancelledError: raise except asyncio.TimeoutError: continue except Exception as e: logger.warning(f"IRC read error: {e}") continue if not line: await asyncio.sleep(0.5) continue if 'PONG' in line or line.startswith('PING'): last_message_time = datetime.now() continue msg = self._parse_message(line) if not msg: continue last_message_time = datetime.now() # Обновляем таймер stats["messages"] += 1 logger.debug(f"IRC message 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: """Закрытие соединения.""" self._connected = False if self._writer: try: self._writer.close() await self._writer.wait_closed() except (ConnectionResetError, BrokenPipeError, OSError) as e: logger.debug(f"Disconnect warning: {e}") self._writer = None self._reader = None