diff --git a/.dockerignore b/.dockerignore index ddf2bb1..c8676eb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -78,7 +78,7 @@ temp/ # Конфиденциальные файлы и настройки # ------------------------------- .env -env/ +/env *.session *.key *.pem diff --git a/Dockerfile b/Dockerfile index 74ff6c7..f6fa801 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,52 @@ -# Используем официальный образ Python с подходящей версией -FROM python:3.13-slim +# ---------- BUILDER ---------- +FROM python:3.13-slim AS builder +ENV PYTHONUNBUFFERED=1 +ENV POETRY_VIRTUALENVS_CREATE=false + +WORKDIR /build + +# Устанавливаем системные зависимости только в builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + nodejs \ + npm \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* +# Обновляем pip +RUN pip install --upgrade pip # Устанавливаем Poetry RUN pip install poetry -# Устанавливаем рабочую директорию внутри контейнера -WORKDIR /app - -# Копируем файлы Poetry +# Копируем файлы зависимостей COPY pyproject.toml poetry.lock* ./ -# Настраиваем Poetry (не создавать виртуальное окружение внутри контейнера) -RUN poetry config virtualenvs.create false - -# Устанавливаем зависимости через Poetry +# Устанавливаем зависимости RUN poetry install --no-interaction --no-ansi --no-root -# Копируем все файлы проекта внутрь контейнера -COPY . . +# ---------- RUNTIME ---------- +FROM python:3.13-slim -# Устанавливаем переменную окружения для буферизации ENV PYTHONUNBUFFERED=1 -# Команда запуска — запуск скрипта main.py +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +# Копируем Python зависимости +COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13 +COPY --from=builder /usr/local/bin /usr/local/bin + +# Копируем ffmpeg бинарник +COPY --from=builder /usr/bin/ffmpeg /usr/bin/ffmpeg + +# Копируем node runtime (для yt-dlp) +COPY --from=builder /usr/bin/node /usr/bin/node +COPY --from=builder /usr/bin/npm /usr/bin/npm + +# Копируем проект +COPY . . + +# Команда запуска CMD ["python", "main.py"] diff --git a/bot/bot.py b/bot/bot.py index 4c819d4..cbce1a3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -30,6 +30,7 @@ class Bot(commands.Bot): intents.message_content = True intents.members = True intents.presences = True + intents.voice_states = True command_prefix: str = prefix or getattr(settings, "PREFIX", "!") @@ -74,6 +75,7 @@ class Bot(commands.Bot): "bot.cogs.blacklist", "bot.cogs.reminders", "bot.cogs.slash", + "bot.cogs.music", ] for cog in cogs: try: diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py index fb15df6..aee1324 100644 --- a/bot/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -2,3 +2,4 @@ from .events import * from .blacklist import * from .reminders import * from .moderation import * +from .music import * diff --git a/bot/cogs/music.py b/bot/cogs/music.py new file mode 100644 index 0000000..f908f40 --- /dev/null +++ b/bot/cogs/music.py @@ -0,0 +1,154 @@ +from __future__ import annotations +import asyncio +from typing import Optional, Dict, Any, List + +import discord +from discord.ext import commands +import yt_dlp + +YTDL_FORMAT_OPTIONS: Dict[str, Any] = { + "format": "bestaudio/best", + "noplaylist": True, + "quiet": True, +} + +FFMPEG_OPTIONS: Dict[str, str] = { + "options": "-vn", + "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", +} + +ytdl: yt_dlp.YoutubeDL = yt_dlp.YoutubeDL(YTDL_FORMAT_OPTIONS) + + +class Music(commands.Cog): + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.queue: Dict[int, List[str]] = {} + + async def connect_to_voice(self, ctx: commands.Context) -> Optional[discord.VoiceClient]: + if ctx.author.voice is None: + await ctx.send("❌ Вы должны находиться в голосовом канале.") + return None + + voice_client: Optional[discord.VoiceClient] = ctx.voice_client + + if voice_client is None: + try: + voice_client = await ctx.author.voice.channel.connect() + except Exception: + await ctx.send("❌ Не удалось подключиться к голосовому каналу.") + return None + + return voice_client + + async def get_audio_data(self, query: str) -> Optional[Dict[str, Any]]: + """Получает данные аудио по URL или поисковому запросу.""" + try: + if query.startswith("http"): + data = ytdl.extract_info(query, download=False) + else: + data = ytdl.extract_info(f"ytsearch:{query}", download=False) + if "entries" in data: + data = data["entries"][0] + return data + except Exception: + return None + + async def play_next(self, ctx: commands.Context) -> None: + guild_id = ctx.guild.id + voice_client = ctx.voice_client + + if guild_id in self.queue and self.queue[guild_id]: + query = self.queue[guild_id].pop(0) + await self.play(ctx, query=query) + else: + await ctx.send("📭 Очередь пуста.") + + @commands.command(name="play") + async def play(self, ctx: commands.Context, *, query: str) -> None: + voice_client = await self.connect_to_voice(ctx) + if voice_client is None: + return + + guild_id = ctx.guild.id + + if guild_id not in self.queue: + self.queue[guild_id] = [] + + if voice_client.is_playing(): + self.queue[guild_id].append(query) + await ctx.send("➕ Трек добавлен в очередь.") + return + + data = await self.get_audio_data(query) + + if data is None: + await ctx.send("❌ Не удалось найти трек.") + return + + stream_url: str = data["url"] + title: str = data.get("title", "Неизвестный трек") + + try: + source = await discord.FFmpegOpusAudio.from_probe( + stream_url, + executable="ffmpeg", + **FFMPEG_OPTIONS + ) + except Exception: + await ctx.send("❌ Ошибка воспроизведения.") + return + + def after_playing(error): + fut = asyncio.run_coroutine_threadsafe(self.play_next(ctx), self.bot.loop) + try: + fut.result() + except Exception: + pass + + voice_client.play(source, after=after_playing) + + await ctx.send(f"▶️ Сейчас играет: **{title}**") + + @commands.command(name="skip") + async def skip(self, ctx: commands.Context) -> None: + voice_client: Optional[discord.VoiceClient] = ctx.voice_client + + if voice_client is None or not voice_client.is_playing(): + await ctx.send("❌ Сейчас ничего не играет.") + return + + voice_client.stop() + await ctx.send("⏭️ Трек пропущен.") + + @commands.command(name="stop") + async def stop(self, ctx: commands.Context) -> None: + voice_client: Optional[discord.VoiceClient] = ctx.voice_client + + if voice_client is None: + await ctx.send("❌ Бот не подключён.") + return + + guild_id = ctx.guild.id + self.queue[guild_id] = [] + + if voice_client.is_playing(): + voice_client.stop() + + await ctx.send("⏹️ Музыка остановлена.") + + @commands.command(name="leave") + async def leave(self, ctx: commands.Context) -> None: + voice_client: Optional[discord.VoiceClient] = ctx.voice_client + + if voice_client is None: + await ctx.send("❌ Бот не в голосовом канале.") + return + + await voice_client.disconnect() + await ctx.send("👋 Бот вышел из голосового канала.") + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Music(bot)) \ No newline at end of file diff --git a/main.py b/main.py index b66c8b3..3f802a4 100644 --- a/main.py +++ b/main.py @@ -15,3 +15,4 @@ async def main() -> None: if __name__ == "__main__": run(main()) + diff --git a/pyproject.toml b/pyproject.toml index ecf203f..a9b25a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,15 @@ authors = [ license = {text = "MIT"} requires-python = ">=3.11,<4.0" dependencies = [ - "discord (>=2.3.2,<3.0.0)", "loguru (>=0.7.3,<0.8.0)", "email-validator (>=2.3.0,<3.0.0)", "pydantic (>=2.12.5,<3.0.0)", - "pydantic-settings (>=2.12.0,<3.0.0)" -] + "pydantic-settings (>=2.12.0,<3.0.0)", + "yt_dlp(>=2026.3.3)", + "pynacl(>=1.6.2)", + "discord(>=2.3.2,<3.0.0)", + "davey(>=0.1.4)" + ]