commit 5108bbbe764c88f4cc7d54b486f4277ae614f547 Author: DIvan2000 Date: Thu Aug 14 02:38:56 2025 +0400 init commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8f815d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +/data/sync_token.txt +/data/.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5ed784 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/sync_token.txt +data/.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc7f196 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.13-alpine +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "main.py"] \ No newline at end of file diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..3b2880c --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,58 @@ +from nio import AsyncClient, LoginResponse + +from .commands import CommandHandler +from .events import EventHandler +from .utils import save_sync_token, load_sync_token + + +class PingPongBot: + def __init__(self, config): + self.config = config + self.client = AsyncClient(config["homeserver_url"], config["bot_user"]) + self.sync_token_file = "data/sync_token.txt" + + # Инициализация подсистем + self.command_handler = CommandHandler(self) + self.event_handler = EventHandler(self) + + async def send_text(self, room_id, text): + """Отправка текстового сообщения""" + await self.client.room_send( + room_id=room_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": text} + ) + + async def start(self): + """Запуск основного цикла бота""" + # Логин + login_response = await self.client.login(self.config["bot_password"]) + if not isinstance(login_response, LoginResponse): + print("[ERROR] Login failed") + return + print(f"[INFO] Logged in as {self.client.user_id}") + + # Регистрация обработчиков событий + self.event_handler.register_callbacks() + + # Загрузка токена синхронизации + sync_token = load_sync_token(self.sync_token_file) + + # Запуск бесконечного sync + try: + await self.client.sync_forever( + timeout=30000, + since=sync_token, + full_state=False, + set_presence="online", + loop_sleep_time=1500 + ) + except KeyboardInterrupt: + print("[INFO] Stopping bot...") + finally: + await self.client.close() + + async def on_sync(self, response): + """Обработчик синхронизации (сохранение токена)""" + if hasattr(response, "next_batch") and response.next_batch: + save_sync_token(self.sync_token_file, response.next_batch) \ No newline at end of file diff --git a/bot/commands.py b/bot/commands.py new file mode 100644 index 0000000..f05687e --- /dev/null +++ b/bot/commands.py @@ -0,0 +1,174 @@ +import re +import os +import time +from .download_audio_services import get_downloader + +class CommandHandler: + def __init__(self, bot): + self.bot = bot + self.commands = self._register_commands() + + def _register_commands(self): + """Регистрация всех доступных команд""" + return { + "help": { + "handler": self.cmd_help, + "description": "Показать справку по командам", + "public": True + }, + "ping": { + "handler": self.cmd_ping, + "description": "Проверить работоспособность бота", + "public": True + }, + "mp3": { + "handler": self.cmd_mp3, + "description": f"Скачать аудио из YouTube. Использование: {self.bot.config['command_prefix']} mp3 ", + "public": True + }, + # Здесь можно добавлять новые команды + } + + def generate_help_text(self, detailed=False): + """Генерация текста справки""" + if detailed: + text = f"**Полная справка по командам бота**\n\nВсе команды начинаются с `{self.bot.config['command_prefix']}`:\n" + for cmd, info in self.commands.items(): + if info.get("public", False): + text += f"\n• `{self.bot.config['command_prefix']} {cmd}` - {info['description']}" + text += "\n\nБот игнорирует сообщения без префикса команды." + return text + + return ( + f"Привет! Я бот PingPong. Для взаимодействия используйте команды " + f"с префиксом `{self.bot.config['command_prefix']}`. " + f"Напишите `{self.bot.config['command_prefix']} help` для получения полной справки." + ) + + # --- Обработчики команд --- + async def cmd_help(self, room_id, event, args): + await self.bot.send_text(room_id, self.generate_help_text(detailed=True)) + async def cmd_ping(self, room_id, event, args): + await self.bot.send_text(room_id, "pong") + + async def cmd_mp3(self, room_id, event, args): + """Обработчик команды mp3""" + if not args: + await self.bot.send_text( + room_id, + "❌ Укажите URL видео. Например: `/media mp3 https://www.youtube.com/watch?v=...`" + ) + return + + url = args.strip() + download_config = self.bot.config.get('download_audio', {}) + downloader_config = None + + # Определяем загрузчик для URL + + downloader = None + for service, config in download_config.get('services', {}).items(): + if config.get('enabled', False): + downloader_config = config + dl_class = get_downloader(service) + if dl_class: + downloader = dl_class() + if downloader.is_supported_url(url): + break + else: + downloader = None + downloader_config = None + + if not downloader: + await self.bot.send_text(room_id, "❌ Этот сервис не поддерживается.") + return + + # Уведомляем о начале загрузки + await self.bot.send_text(room_id, "⏳ Начинаю загрузку... Это может занять несколько минут.") + + try: + # Запускаем загрузку + file_path, title_or_error, duration = await downloader.download_audio(url, downloader_config) + + if not file_path: + raise Exception(title_or_error or "Ошибка загрузки") + + # Простой метод отправки файла + with open(file_path, 'rb') as f: + # Получаем размер файла + file_size = os.path.getsize(file_path) + + # Загружаем файл на сервер Matrix + mime_type = "audio/mpeg" + response, _ = await self.bot.client.upload( + f, + content_type=mime_type, + filename=f"{title_or_error}.mp3" + ) + + # Формируем сообщение с аудио + audio_content = { + "body": f"{title_or_error}.mp3", + "info": { + "mimetype": mime_type, + "size": file_size, + "duration": duration or 0 # Можно оставить 0, если длительность неизвестна + }, + "msgtype": "m.audio", + "url": response.content_uri, + } + + # Отправляем сообщение + await self.bot.client.room_send( + room_id, + message_type="m.room.message", + content=audio_content + ) + + await self.bot.send_text(room_id, "✅ Готово! Аудио отправлено.") + + except Exception as e: + error_msg = f"⚠️ Ошибка при загрузке: {str(e)}" + await self.bot.send_text(room_id, error_msg) + print(f"[ERROR] MP3 download failed: {str(e)}") + + finally: + # Очистка временных файлов + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + except: + pass + + + + # --- Обработка входящих команд --- + async def process_command(self, room_id, event): + """Обработка и выполнение команд""" + msg = event.body.strip() + command_prefix = self.bot.config["command_prefix"] + + # Проверяем префикс команды + if not msg.startswith(command_prefix): + return + + # Извлекаем команду и аргументы + cmd_parts = re.split(r'\s+', msg[len(command_prefix):].strip(), maxsplit=1) + command = cmd_parts[0].lower() + args = cmd_parts[1] if len(cmd_parts) > 1 else "" + + # Ищем обработчик команды + cmd_info = self.commands.get(command) + if not cmd_info: + await self.bot.send_text( + room_id, + f"Неизвестная команда. Используйте `{command_prefix} help` для справки." + ) + return + + try: + # Выполняем команду + await cmd_info["handler"](room_id, event, args) + except Exception as e: + print(f"[ERROR] Command '{command}' failed: {str(e)}") + await self.bot.send_text(room_id, "⚠️ Произошла ошибка при выполнении команды") \ No newline at end of file diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..393d263 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,32 @@ +import yaml +import os + + +def load_config(filename="data/config.yaml"): + """Загрузка конфигурации с установкой значений по умолчанию""" + try: + if not os.path.exists(filename): + raise FileNotFoundError(f"Config file not found: {filename}") + + with open(filename, "r") as file: + config = yaml.safe_load(file) + + # Установка значений по умолчанию + defaults = { + "command_prefix": "/media", + "trusted_homeservers": [] + } + + for key, value in defaults.items(): + config.setdefault(key, value) + + # Проверка обязательных параметров + required = ["homeserver_url", "bot_user", "bot_password"] + for param in required: + if param not in config: + raise ValueError(f"Missing required config parameter: {param}") + + return config + except (FileNotFoundError, yaml.YAMLError, ValueError) as e: + print(f"[CRITICAL] Config error: {str(e)}") + exit(1) \ No newline at end of file diff --git a/bot/download_audio_services/__init__.py b/bot/download_audio_services/__init__.py new file mode 100644 index 0000000..7588cc4 --- /dev/null +++ b/bot/download_audio_services/__init__.py @@ -0,0 +1,10 @@ +from .base import Downloader +from .youtube import YouTubeDownloader + +def get_downloader(service_name: str) -> Downloader: + """Фабрика загрузчиков""" + downloaders = { + "youtube": YouTubeDownloader, + # Здесь можно регистрировать новые загрузчики + } + return downloaders.get(service_name.lower()) \ No newline at end of file diff --git a/bot/download_audio_services/base.py b/bot/download_audio_services/base.py new file mode 100644 index 0000000..d7b9e39 --- /dev/null +++ b/bot/download_audio_services/base.py @@ -0,0 +1,23 @@ +import abc +import os +import re +from typing import Optional, Tuple + + +class Downloader(abc.ABC): + """Базовый класс для загрузчиков""" + + @abc.abstractmethod + def is_supported_url(self, url: str) -> bool: + """Проверяет, поддерживается ли URL""" + pass + + @abc.abstractmethod + async def download_audio(self, url: str, config: dict) -> Tuple[Optional[str], Optional[str], Optional[int]]: + """Скачивает аудио и возвращает (путь к файлу, название, длительность)""" + pass + + @staticmethod + def sanitize_filename(name: str) -> str: + """Очищает название файла от недопустимых символов""" + return re.sub(r'[^\w\-_\. ]', '', name)[:100] \ No newline at end of file diff --git a/bot/download_audio_services/youtube.py b/bot/download_audio_services/youtube.py new file mode 100644 index 0000000..d71e4aa --- /dev/null +++ b/bot/download_audio_services/youtube.py @@ -0,0 +1,62 @@ +import asyncio +import os +import uuid +import yt_dlp +from typing import Optional, Tuple +from .base import Downloader +from bot.utils import run_blocking + + +class YouTubeDownloader(Downloader): + """Загрузчик для YouTube""" + + def is_supported_url(self, url: str) -> bool: + return "youtube.com" in url or "youtu.be" in url + + async def download_audio(self, url: str, config: dict) -> Tuple[Optional[str], Optional[str], Optional[int]]: + """Скачивает аудио с YouTube""" + # Настройки yt-dlp + ydl_opts = { + 'format': 'bestaudio/best', + 'outtmpl': os.path.join(config['tmp_dir'], f'%(id)s.%(ext)s'), + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': str(config.get('audio_quality', 192)), + }], + 'quiet': True, + 'no_warnings': True, + 'proxy': config.get('proxy'), + 'max_filesize': 50 * 1024 * 1024, # 100MB + 'noplaylist': True, + } + + # Ограничение длительности + if max_duration := config.get('max_duration'): + ydl_opts['match_filter'] = yt_dlp.match_filter_func( + f"duration < {max_duration}" + ) + + try: + # Запускаем в отдельном процессе + result = await run_blocking(self._download, url, ydl_opts) + return result + except Exception as e: + return None, str(e) + + def _download(self, url: str, opts: dict) -> Tuple[str, str, int]: + """Синхронная загрузка (выполняется в отдельном процессе)""" + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=True) + filename = ydl.prepare_filename(info) + base, _ = os.path.splitext(filename) + mp3_path = base + '.mp3' + + # Получаем название + title = info.get('title', 'audio') or 'audio' + clean_title = self.sanitize_filename(title) + + # Получаем длительность в миллисекундах + duration_ms = int(info.get('duration', 0) * 1000) + + return mp3_path, clean_title, duration_ms \ No newline at end of file diff --git a/bot/events.py b/bot/events.py new file mode 100644 index 0000000..f04141f --- /dev/null +++ b/bot/events.py @@ -0,0 +1,43 @@ +import os + +from nio import RoomMessageText, InviteMemberEvent +from .utils import is_trusted_room + + +class EventHandler: + def __init__(self, bot): + self.bot = bot + + def register_callbacks(self): + """Регистрация обработчиков событий""" + self.bot.client.add_event_callback(self.invite_callback, InviteMemberEvent) + self.bot.client.add_event_callback(self.message_callback, RoomMessageText) + self.bot.client.add_response_callback(self.bot.on_sync) + + async def invite_callback(self, room, event): + """Обработчик приглашений в комнаты""" + if event.state_key == self.bot.client.user_id: + if is_trusted_room(room.room_id, self.bot.config["trusted_homeservers"]): + print(f"[INFO] Joining trusted room: {room.room_id}") + try: + await self.bot.client.join(room.room_id) + # Отправляем приветственное сообщение + help_text = self.bot.command_handler.generate_help_text(detailed=False) + await self.bot.send_text(room.room_id, help_text) + except Exception as e: + print(f"[ERROR] Failed to join/send welcome: {str(e)}") + else: + print(f"[WARN] Ignored invite to untrusted room: {room.room_id}") + + async def message_callback(self, room, event): + """Обработчик входящих сообщений""" + # Игнорируем свои сообщения + if event.sender == self.bot.client.user_id: + return + + # Не отвечаем если не загружен sync token + if not os.path.exists(self.bot.sync_token_file): + return + + # Обрабатываем команду + await self.bot.command_handler.process_command(room.room_id, event) \ No newline at end of file diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..b30bdbd --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,53 @@ +import asyncio +import os + + +def save_sync_token(filename, token): + """Атомарное сохранение токена синхронизации""" + try: + temp_file = filename + ".tmp" + with open(temp_file, "w") as f: + f.write(token) + os.replace(temp_file, filename) + except IOError as e: + print(f"[ERROR] Failed to save sync token: {str(e)}") + + +def load_sync_token(filename): + """Загрузка токена синхронизации""" + try: + if os.path.exists(filename): + with open(filename, "r") as f: + token = f.read().strip() + if token: + print(f"[INFO] Loaded sync token: {token[:10]}...") + return token + return None + except IOError as e: + print(f"[ERROR] Failed to load sync token: {str(e)}") + return None + + +def is_trusted_room(room_id, trusted_domains): + """Проверка, находится ли комната на доверенном сервере""" + if not room_id.startswith("!"): + print(f"[WARN] Invalid room ID format: {room_id}") + return False + + # Извлекаем домен комнаты + parts = room_id.split(":", 1) + if len(parts) < 2: + return False + + room_domain = parts[1].lower() + + # Нормализация доверенных доменов + trusted_domains = [domain.lower().strip() for domain in trusted_domains] + + return room_domain in trusted_domains + + +def run_blocking(func, *args): + """Запускает блокирующую функцию в отдельном потоке""" + loop = asyncio.get_running_loop() + return loop.run_in_executor(None, func, *args) \ No newline at end of file diff --git a/data/config.yaml b/data/config.yaml new file mode 100644 index 0000000..ac4d425 --- /dev/null +++ b/data/config.yaml @@ -0,0 +1,16 @@ +homeserver_url: "https://example.org" +bot_user: "@MediaBot:example.org" +bot_password: "bot_password" +trusted_homeservers: + - "example.org" +command_prefix: "!media" + +download_audio: + services: + youtube: + tmp_dir: "/tmp" # Директория для временных файлов + timeout: 300 # Таймаут загрузки в секундах + proxy: none # Прокси для загрузки (например: "socks5://user:pass@host:port") + enabled: true + audio_quality: 192 # Качество аудио в kbps + max_duration: 1200 # Макс. длительность в секундах \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..dfdbf9b --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +import asyncio +import os + +from bot.bot import PingPongBot +from bot.config import load_config + +if __name__ == "__main__": + # Загрузка конфигурации + config = load_config() + + env_bot_user = os.getenv("BOT_USER") + if env_bot_user: + config["bot_user"] = env_bot_user + + env_bot_password = os.getenv("BOT_PASSWORD") + if env_bot_password: + config["bot_password"] = env_bot_password + + # Создание и запуск бота + bot = PingPongBot(config) + asyncio.run(bot.start()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fd2e94d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiohttp_socks==0.10.1 +aiosignal==1.4.0 +aiosqlite==0.21.0 +annotated-types==0.7.0 +attrs==25.3.0 +beautifulsoup4==4.13.4 +blurhash-python==1.2.2 +cffi==1.17.1 +ffmpeg-python==0.2.0 +frozenlist==1.7.0 +future==1.0.0 +h11==0.16.0 +h2==4.2.0 +hpack==4.1.0 +hyperframe==6.1.0 +idna==3.10 +jsonschema==4.25.0 +jsonschema-specifications==2025.4.1 +marko==2.2.0 +matrix-nio==0.25.2 +multidict==6.6.4 +nio-bot==1.2.1 +orjson==3.11.2 +pillow==11.3.0 +propcache==0.3.2 +pycparser==2.22 +pycryptodome==3.23.0 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-magic==0.4.27 +python-socks==2.7.2 +PyYAML==6.0.2 +referencing==0.36.2 +rpds-py==0.27.0 +safepickle==0.2.0 +six==1.17.0 +soupsieve==2.7 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +unpaddedbase64==2.1.0 +yarl==1.20.1 +yt-dlp==2025.8.11