init commit
This commit is contained in:
commit
5108bbbe76
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
/data/sync_token.txt
|
||||
/data/.env
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
data/sync_token.txt
|
||||
data/.env
|
6
Dockerfile
Normal file
6
Dockerfile
Normal 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
58
bot/bot.py
Normal 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
174
bot/commands.py
Normal 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
32
bot/config.py
Normal 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)
|
10
bot/download_audio_services/__init__.py
Normal file
10
bot/download_audio_services/__init__.py
Normal 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())
|
23
bot/download_audio_services/base.py
Normal file
23
bot/download_audio_services/base.py
Normal 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]
|
62
bot/download_audio_services/youtube.py
Normal file
62
bot/download_audio_services/youtube.py
Normal 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
43
bot/events.py
Normal 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
53
bot/utils.py
Normal 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
16
data/config.yaml
Normal 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
21
main.py
Normal 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
45
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user