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