Files
Click/services/irc_service.py
T
Yuriy Yuriev 44ce29d2e0 fix
2026-07-05 16:59:33 +07:00

293 lines
11 KiB
Python

"""
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