init commit

This commit is contained in:
DIvan2000 2025-08-14 02:38:56 +04:00
commit 5108bbbe76
14 changed files with 547 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
/data/sync_token.txt
/data/.env

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
data/sync_token.txt
data/.env

6
Dockerfile Normal file
View File

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

58
bot/bot.py Normal file
View File

@ -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)

174
bot/commands.py Normal file
View File

@ -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 <url>",
"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, "⚠️ Произошла ошибка при выполнении команды")

32
bot/config.py Normal file
View File

@ -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)

View File

@ -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())

View File

@ -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]

View File

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

43
bot/events.py Normal file
View File

@ -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)

53
bot/utils.py Normal file
View File

@ -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)

16
data/config.yaml Normal file
View File

@ -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 # Макс. длительность в секундах

21
main.py Normal file
View File

@ -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())

45
requirements.txt Normal file
View File

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