From 6f29d7a5fff3c8e24a2273ab216164838225deb5 Mon Sep 17 00:00:00 2001 From: valer Date: Sat, 7 Mar 2026 16:35:52 +0700 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=81=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D1=8B=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 26 +++---- bot/cogs/blacklist.py | 17 +++-- bot/cogs/moderation.py | 66 +++++++++--------- bot/cogs/music.py | 152 ++++++++++++++++++++--------------------- bot/cogs/reminders.py | 98 +++++++++++--------------- 5 files changed, 171 insertions(+), 188 deletions(-) diff --git a/Dockerfile b/Dockerfile index f6fa801..bfb8ab8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ ENV POETRY_VIRTUALENVS_CREATE=false WORKDIR /build -# Устанавливаем системные зависимости только в builder RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg \ @@ -15,15 +14,12 @@ RUN apt-get update \ build-essential \ curl \ && rm -rf /var/lib/apt/lists/* -# Обновляем pip + RUN pip install --upgrade pip -# Устанавливаем Poetry RUN pip install poetry -# Копируем файлы зависимостей COPY pyproject.toml poetry.lock* ./ -# Устанавливаем зависимости RUN poetry install --no-interaction --no-ansi --no-root # ---------- RUNTIME ---------- @@ -33,20 +29,18 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* -# Копируем Python зависимости +# Устанавливаем runtime зависимости +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + nodejs \ + && 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"] +CMD ["python", "main.py"] \ No newline at end of file diff --git a/bot/cogs/blacklist.py b/bot/cogs/blacklist.py index 2e719f5..5457187 100644 --- a/bot/cogs/blacklist.py +++ b/bot/cogs/blacklist.py @@ -1,5 +1,5 @@ from discord.ext.commands import Cog, Bot, command, Context - +import discord from ..storage import storage from .moderation import is_admin @@ -12,7 +12,10 @@ class Blacklist(Cog): def __init__(self, bot: Bot) -> None: self.bot: Bot = bot - @command() + @discord.app_commands.command( + name="blacklist_show", + description="Показать текущий чёрный список слов", + ) @is_admin() async def blacklist_show(self, ctx: Context) -> None: """ @@ -25,7 +28,10 @@ class Blacklist(Cog): else: await ctx.send("Чёрный список:\n" + ", ".join(storage.blacklist)) - @command() + @discord.app_commands.command( + name="blacklist_add", + description="Добавить слово в чёрный список", + ) @is_admin() async def blacklist_add(self, ctx: Context, *, word: str) -> None: """ @@ -43,7 +49,10 @@ class Blacklist(Cog): storage.save_blacklist() await ctx.send(f"Слово `{word_lower}` добавлено в чёрный список.") - @command() + @discord.app_commands.command( + name="blacklist_remove", + description="Удалить слово из чёрного списка", + ) @is_admin() async def blacklist_remove(self, ctx: Context, *, word: str) -> None: """ diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index f4d59cc..d14c6c0 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -55,25 +55,11 @@ class Moderation(Cog): """ self.bot: Bot = bot - @command() - @is_admin() - async def rules(self, ctx: Context) -> None: - """ - Показать правила сервера. - :param ctx: Контекст команды. - """ - rules_text: str = ( - "**Правила сервера:**\n" - "1. Уважайте других участников.\n" - "2. Запрещена реклама и спам.\n" - "3. Не используйте запрещённые слова.\n" - "4. Соблюдайте тематику каналов.\n" - "5. Выполняйте указания модераторов.\n" - ) - await ctx.send(rules_text) - - @command() + @discord.app_commands.command( + name="kick", + description="Исключить участника с сервера", + ) @is_admin() async def kick( self, @@ -97,15 +83,12 @@ class Moderation(Cog): except discord.HTTPException: await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.") - @command() + @discord.app_commands.command( + name="ban", + description="Забанить участника на сервере", + ) @is_admin() - async def ban( - self, - ctx: Context, - member: discord.Member, - *, - reason: Optional[str] = None, - ) -> None: + async def ban(self,ctx: Context,member: discord.Member,*,reason: Optional[str] = None) -> None: """ Забанить участника на сервере. @@ -124,7 +107,10 @@ class Moderation(Cog): except discord.HTTPException: await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.") - @command() + @discord.app_commands.command( + name="unban", + description="Разбанить пользователя по имени или тегу", + ) @has_permissions(ban_members=True) async def unban(self, ctx: Context, *, member_name: str) -> None: """ @@ -193,7 +179,10 @@ class Moderation(Cog): msg_lines.append(f"- {user.name}#{user.discriminator}") await ctx.send("\n".join(msg_lines)) - @command() + @discord.app_commands.command( + name="mute", + description="Выдать участнику мут", + ) @is_admin() async def mute( self, @@ -227,7 +216,10 @@ class Moderation(Cog): except discord.HTTPException: await ctx.send("Не удалось выдать мут из-за ошибки Discord.") - @command() + @discord.app_commands.command( + name="unmute", + description="Снять мут с участника", + ) @is_admin() async def unmute(self, ctx: Context, member: discord.Member) -> None: """ @@ -254,7 +246,10 @@ class Moderation(Cog): except discord.HTTPException: await ctx.send("Не удалось снять мут из-за ошибки Discord.") - @command() + @discord.app_commands.command( + name="warn", + description="Выдать предупреждение участнику", + ) @is_admin() async def warn( self, @@ -289,8 +284,10 @@ class Moderation(Cog): if warns_count >= max_warning: await self.ban(ctx,member,reason=f"Превышен лимит предупреждений ({warns_count})") - - @command() + @discord.app_commands.command( + name="warnings", + description="Показать предупреждения участника", + ) async def warnings(self, ctx: Context, member: discord.Member) -> None: """ Показать предупреждения участника. @@ -309,7 +306,10 @@ class Moderation(Cog): lines.append(f"{i}. {w['reason']} ({w['date']})") await ctx.send("\n".join(lines)) - @command() + @discord.app_commands.command( + name="clear", + description="Очистить указанное количество сообщений в канале", + ) @is_admin() async def clear(self, ctx: Context, amount: int) -> None: """ diff --git a/bot/cogs/music.py b/bot/cogs/music.py index f908f40..87518e2 100644 --- a/bot/cogs/music.py +++ b/bot/cogs/music.py @@ -1,49 +1,60 @@ from __future__ import annotations import asyncio -from typing import Optional, Dict, Any, List +from typing import Dict, List, Any, Optional import discord from discord.ext import commands import yt_dlp -YTDL_FORMAT_OPTIONS: Dict[str, Any] = { +# yt-dlp конфиг с поддержкой поиска и node +YTDL_OPTIONS: Dict[str, Any] = { "format": "bestaudio/best", "noplaylist": True, "quiet": True, + "default_search": "ytsearch1", + "source_address": "0.0.0.0", + "js_runtimes": { + "node": {"path": "/usr/bin/node"} + }, + "remote_components": { + "ejs:github": "github" + }, + "extractor_args": { + "youtube": { + "player_client": ["web_music"] + } + } } FFMPEG_OPTIONS: Dict[str, str] = { "options": "-vn", - "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", + "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" } -ytdl: yt_dlp.YoutubeDL = yt_dlp.YoutubeDL(YTDL_FORMAT_OPTIONS) +ytdl = yt_dlp.YoutubeDL(YTDL_OPTIONS) class Music(commands.Cog): - - def __init__(self, bot: commands.Bot) -> None: + def __init__(self, bot: commands.Bot): self.bot = bot - self.queue: Dict[int, List[str]] = {} + self.queue: Dict[int, List[str]] = {} # guild_id -> список запросов - async def connect_to_voice(self, ctx: commands.Context) -> Optional[discord.VoiceClient]: - if ctx.author.voice is None: - await ctx.send("❌ Вы должны находиться в голосовом канале.") + async def connect_voice(self, interaction: discord.Interaction) -> Optional[discord.VoiceClient]: + if not interaction.user.voice or not interaction.user.voice.channel: + await interaction.response.send_message("❌ Вы должны быть в голосовом канале.", ephemeral=True) return None - voice_client: Optional[discord.VoiceClient] = ctx.voice_client - + voice_client = interaction.guild.voice_client if voice_client is None: try: - voice_client = await ctx.author.voice.channel.connect() - except Exception: - await ctx.send("❌ Не удалось подключиться к голосовому каналу.") + voice_client = await interaction.user.voice.channel.connect() + except Exception as e: + await interaction.response.send_message(f"❌ Не удалось подключиться: {e}", ephemeral=True) return None return voice_client - async def get_audio_data(self, query: str) -> Optional[Dict[str, Any]]: - """Получает данные аудио по URL или поисковому запросу.""" + async def get_audio(self, query: str) -> Optional[Dict[str, Any]]: try: if query.startswith("http"): data = ytdl.extract_info(query, download=False) @@ -55,100 +66,89 @@ class Music(commands.Cog): except Exception: return None - async def play_next(self, ctx: commands.Context) -> None: - guild_id = ctx.guild.id - voice_client = ctx.voice_client + async def play_next(self, guild_id: int, channel: discord.TextChannel) -> None: + if guild_id not in self.queue or not self.queue[guild_id]: + await channel.send("📭 Очередь пуста.") + return - 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("📭 Очередь пуста.") + next_query = self.queue[guild_id].pop(0) + # создаем фиктивный interaction для play + class DummyInteraction: + guild = channel.guild + user = channel.guild.me + response = type('Resp', (), {"send_message": lambda self, msg, ephemeral=False: asyncio.create_task(channel.send(msg))})() - @commands.command(name="play") - async def play(self, ctx: commands.Context, *, query: str) -> None: - voice_client = await self.connect_to_voice(ctx) + await self.play(DummyInteraction(), next_query) + + @discord.app_commands.command(name="play", description="Воспроизвести трек или добавить в очередь") + async def play(self, interaction: discord.Interaction, query: str): + voice_client = await self.connect_voice(interaction) if voice_client is None: return - guild_id = ctx.guild.id - + guild_id = interaction.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("➕ Трек добавлен в очередь.") + await interaction.response.send_message("➕ Трек добавлен в очередь.") return - data = await self.get_audio_data(query) - - if data is None: - await ctx.send("❌ Не удалось найти трек.") + data = await self.get_audio(query) + if not data: + await interaction.response.send_message("❌ Не удалось найти трек.") return - stream_url: str = data["url"] - title: str = data.get("title", "Неизвестный трек") + stream_url = data["url"] + title = data.get("title", "Неизвестный трек") try: - source = await discord.FFmpegOpusAudio.from_probe( + source = discord.FFmpegOpusAudio( stream_url, executable="ffmpeg", **FFMPEG_OPTIONS ) except Exception: - await ctx.send("❌ Ошибка воспроизведения.") + await interaction.response.send_message("❌ Ошибка воспроизведения.") return def after_playing(error): - fut = asyncio.run_coroutine_threadsafe(self.play_next(ctx), self.bot.loop) + fut = asyncio.run_coroutine_threadsafe(self.play_next(guild_id, interaction.channel), self.bot.loop) try: fut.result() except Exception: pass voice_client.play(source, after=after_playing) + await interaction.response.send_message(f"▶️ Сейчас играет: **{title}**") - 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("❌ Сейчас ничего не играет.") + @discord.app_commands.command(name="skip", description="Пропустить текущий трек") + async def skip(self, interaction: discord.Interaction): + voice_client = interaction.guild.voice_client + if not voice_client or not voice_client.is_playing(): + await interaction.response.send_message("❌ Сейчас ничего не играет.", ephemeral=True) return - voice_client.stop() - await ctx.send("⏭️ Трек пропущен.") + await interaction.response.send_message("⏭️ Трек пропущен.") - @commands.command(name="stop") - async def stop(self, ctx: commands.Context) -> None: - voice_client: Optional[discord.VoiceClient] = ctx.voice_client + @discord.app_commands.command(name="stop", description="Остановить музыку и очистить очередь") + async def stop(self, interaction: discord.Interaction): + voice_client = interaction.guild.voice_client + if voice_client: + guild_id = interaction.guild.id + self.queue[guild_id] = [] + if voice_client.is_playing(): + voice_client.stop() + await interaction.response.send_message("⏹️ Музыка остановлена.") - 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("👋 Бот вышел из голосового канала.") + @discord.app_commands.command(name="leave", description="Отключить бота от голосового канала") + async def leave(self, interaction: discord.Interaction): + voice_client = interaction.guild.voice_client + if voice_client: + await voice_client.disconnect() + await interaction.response.send_message("👋 Бот вышел из голосового канала.") -async def setup(bot: commands.Bot) -> None: +async def setup(bot: commands.Bot): await bot.add_cog(Music(bot)) \ No newline at end of file diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index baa22ad..a726a61 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,94 +1,74 @@ from datetime import datetime, timedelta +from typing import Optional -from discord.ext.commands import Cog, Bot, Context, group +import discord +from discord.ext import commands +from discord import app_commands from ..storage import storage, Reminder from .moderation import is_admin -class Reminders(Cog): - """ - Cog для управления напоминаниями: add, list, remove. - """ - def __init__(self, bot: Bot) -> None: - self.bot: Bot = bot +class Reminders(commands.Cog): + """Cog для управления напоминаниями: add, list, remove через slash-команды.""" - @group() + def __init__(self, bot: commands.Bot): + self.bot = bot + + reminder_group = app_commands.Group(name="reminder", description="Управление напоминаниями") + + @reminder_group.command(name="add", description="Добавить новое напоминание") @is_admin() - async def reminder(self, ctx: Context) -> None: - """ - Группа команд напоминаний. - - :param ctx: Контекст команды. - """ - if ctx.invoked_subcommand is None: - await ctx.send( - "Используйте `!reminder add <минуты> <текст>`, " - "`!reminder list` или `!reminder remove <номер>`" - ) - - @reminder.command(name="add") + @app_commands.describe(minutes="Через сколько минут сработает напоминание", text="Текст напоминания") async def reminder_add( - self, ctx: Context, minutes: int, *, text: str + self, interaction: discord.Interaction, minutes: int, text: str ) -> None: - """ - Добавить новое напоминание. - - :param ctx: Контекст команды. - :param minutes: Через сколько минут сработает напоминание. - :param text: Текст напоминания. - """ if minutes <= 0: - await ctx.send("Время должно быть положительным числом минут.") + await interaction.response.send_message( + "Время должно быть положительным числом минут.", ephemeral=True + ) return - remind_time: datetime = datetime.now() + timedelta(minutes=minutes) + remind_time = datetime.now() + timedelta(minutes=minutes) storage.reminders.append( Reminder( time=remind_time.timestamp(), - channel_id=ctx.channel.id, # type: ignore[assignment] - user_mention=ctx.author.mention, # type: ignore[union-attr] + channel_id=interaction.channel.id, # type: ignore + user_mention=interaction.user.mention, # type: ignore text=text, ) ) storage.save_reminders() - await ctx.send(f"Напоминание добавлено через {minutes} минут: {text}") + await interaction.response.send_message( + f"✅ Напоминание добавлено через {minutes} минут: {text}" + ) - @reminder.command(name="list") - async def reminder_list(self, ctx: Context) -> None: - """ - Показать список активных напоминаний. - - :param ctx: Контекст команды. - """ + @reminder_group.command(name="list", description="Показать список активных напоминаний") + @is_admin() + async def reminder_list(self, interaction: discord.Interaction) -> None: if not storage.reminders: - await ctx.send("Активных напоминаний нет.") + await interaction.response.send_message("Активных напоминаний нет.", ephemeral=True) return - msg: str = "Активные напоминания:\n" + msg = "📋 Активные напоминания:\n" for i, rem in enumerate(storage.reminders, 1): - t_str: str = datetime.fromtimestamp(rem.time).strftime( - "%Y-%m-%d %H:%M:%S" - ) + t_str = datetime.fromtimestamp(rem.time).strftime("%Y-%m-%d %H:%M:%S") msg += f"{i}. До {t_str} — {rem.text} (от {rem.user_mention})\n" - await ctx.send(msg) - @reminder.command(name="remove") - async def reminder_remove(self, ctx: Context, number: int) -> None: - """ - Удалить напоминание по номеру. + await interaction.response.send_message(msg) - :param ctx: Контекст команды. - :param number: Порядковый номер напоминания. - """ + @reminder_group.command(name="remove", description="Удалить напоминание по номеру") + @is_admin() + @app_commands.describe(number="Номер напоминания из списка") + async def reminder_remove(self, interaction: discord.Interaction, number: int) -> None: if number <= 0 or number > len(storage.reminders): - await ctx.send("Неверный номер напоминания.") + await interaction.response.send_message("❌ Неверный номер напоминания.", ephemeral=True) return - removed: Reminder = storage.reminders.pop(number - 1) + removed = storage.reminders.pop(number - 1) storage.save_reminders() - await ctx.send(f"Удалено напоминание: {removed.text}") + await interaction.response.send_message(f"✅ Удалено напоминание: {removed.text}") -async def setup(bot: Bot) -> None: - await bot.add_cog(Reminders(bot)) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Reminders(bot)) \ No newline at end of file