Compare commits

...

23 Commits

Author SHA1 Message Date
ca9dae8106 Merge pull request 'Fixed queue, skip, adding tracks' (#4) from murprite/bot:murprite/fix-bot into master
Some checks failed
CI / basic-checks (push) Failing after 12s
Reviewed-on: #4
2026-03-10 14:52:13 +03:00
80ac57e9e8 Fixed queue, skip, adding tracks
Some checks failed
CI / basic-checks (pull_request) Has been cancelled
2026-03-10 00:15:05 +07:00
6f29d7a5ff Все команды изменены на slash
Some checks failed
CI / basic-checks (push) Failing after 11s
2026-03-07 16:35:52 +07:00
b89b30b7c5 Добавлено прослушивание музыки
Some checks failed
CI / basic-checks (push) Failing after 10s
2026-03-07 13:11:18 +07:00
2c6629f7f9 Добавлена смена никнейма при уходе в AFK
All checks were successful
CI / basic-checks (push) Successful in 51s
2026-03-06 14:25:57 +07:00
cc821bc6e8 Переделал работу warn
All checks were successful
CI / basic-checks (push) Successful in 12s
2025-12-09 18:45:18 +07:00
1cf1e7455a Merge remote-tracking branch 'origin/master'
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 21:50:18 +07:00
65176292b8 переписал help 2025-12-08 21:50:06 +07:00
304937b162 Merge pull request 'переписал help' (#2) from valera into master
All checks were successful
CI / basic-checks (push) Successful in 12s
Reviewed-on: #2
2025-12-08 14:46:13 +00:00
4a3b9a48bb переписал help
All checks were successful
CI / basic-checks (pull_request) Successful in 12s
2025-12-08 21:43:11 +07:00
ae9b716e29 Merge remote-tracking branch 'origin/master'
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 20:35:40 +07:00
b5b16397a4 Исправление пути json 2025-12-08 20:35:28 +07:00
452690ffe8 Merge pull request 'хуй' (#1) from valera into master
All checks were successful
CI / basic-checks (push) Successful in 12s
Reviewed-on: #1
2025-12-08 12:58:53 +00:00
90c09c7550 хуй
All checks were successful
CI / basic-checks (pull_request) Successful in 11s
2025-12-08 19:56:07 +07:00
3c6bb49b4f СВЕРХВАЖНОЕ ОБНОВЛЕНИЕ
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 19:52:19 +07:00
5150846189 Создание списка команд
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 19:00:02 +07:00
6cae88e362 Исправление ошибок с event
All checks were successful
CI / basic-checks (push) Successful in 12s
2025-12-08 18:44:04 +07:00
635a8c0f74 Исправление ошибок с event
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 18:36:25 +07:00
869dd545b7 Исправление ошибок с event
All checks were successful
CI / basic-checks (push) Successful in 12s
2025-12-08 18:19:14 +07:00
25df391f32 Оптимизация import
All checks were successful
CI / basic-checks (push) Successful in 11s
2025-12-08 18:02:52 +07:00
6cc5ea544e Исправление CI-тестов
All checks were successful
CI / basic-checks (push) Successful in 20s
2025-12-08 17:00:39 +07:00
0f7364825a Исправление Poetry менеджера
Some checks failed
CI / basic-checks (push) Failing after 15s
2025-12-08 16:55:27 +07:00
78239b4f9a Исправление CI-тестов
Some checks failed
CI / basic-checks (push) Failing after 10s
2025-12-08 16:53:13 +07:00
18 changed files with 669 additions and 232 deletions

View File

@@ -78,7 +78,7 @@ temp/
# Конфиденциальные файлы и настройки
# -------------------------------
.env
env/
/env
*.session
*.key
*.pem

View File

@@ -26,23 +26,21 @@ jobs:
with:
python-version: "3.13"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: "1.8.3"
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Install dependencies
run: |
poetry install --no-interaction --no-root
python -m pip install --upgrade pip
pip install \
"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"
- name: Basic import checks
run: |
poetry run python -c "import configs; from bot import Bot; print('Bot class ok')"
poetry run python -c "from bot import discbot; print('Global bot instance ok')"
python -c "import configs; from bot import Bot; print('Bot class ok')"
python -c "from bot import discbot; print('Global bot instance ok')"
- name: Load cogs without running bot
run: |
poetry run python -c "from bot import discbot; import asyncio; asyncio.run(discbot.setup()); print('Cogs loaded')"
python -c "from bot import discbot; import asyncio; asyncio.run(discbot.setup()); print('Cogs loaded')"

2
.idea/Bot.iml generated
View File

@@ -5,7 +5,7 @@
<sourceFolder url="file://$MODULE_DIR$/middleware" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (NotFateKursach)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.12 (bot)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.13 (Bot)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (NotFateKursach)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (bot)" project-jdk-type="Python SDK" />
</project>

View File

@@ -1,26 +1,47 @@
# Используем официальный образ Python с подходящей версией
FROM python:3.13-slim
# ---------- BUILDER ----------
FROM python:3.13-slim AS builder
# Устанавливаем Poetry
ENV PYTHONUNBUFFERED=1
ENV POETRY_VIRTUALENVS_CREATE=false
WORKDIR /build
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
nodejs \
npm \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade pip
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
# Устанавливаем runtime зависимости
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g deno
# Копируем python пакеты
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
COPY --from=builder /usr/local/bin /usr/local/bin
# Копируем проект
COPY . .
CMD ["python", "main.py"]

BIN
README.md

Binary file not shown.

View File

@@ -1,5 +1,4 @@
from typing import Optional
import logging
from discord import Intents
from discord.ext import commands
@@ -15,8 +14,7 @@ __all__ = ("Bot", "discbot")
class Bot(commands.Bot):
"""
Основной класс Discord-бота с методами настройки и запуска.
Поддерживает передачу token, prefix, intents и help_command в конструктор.
Поддерживает префиксные и slash-команды.
"""
def __init__(
@@ -26,33 +24,26 @@ class Bot(commands.Bot):
intents: Optional[Intents] = None,
help_command: Optional[commands.HelpCommand] = None,
) -> None:
"""
:param token: Токен бота (если None — берётся из settings.BOT_TOKEN).
:param prefix: Префикс команд (если None — берётся из settings.PREFIX или '!').
:param intents: Intents (если None — создаются стандартные + privileged).
:param help_command: Кастомная команда помощи.
"""
# Intents по умолчанию
if intents is None:
intents = Intents.default()
intents.guilds = True
intents.message_content = True # Требует включения в Developer Portal
intents.message_content = True
intents.members = True
intents.presences = True
intents.voice_states = True
# Префикс по умолчанию
command_prefix: str = prefix or getattr(settings, "PREFIX", "!")
# Help-команда по умолчанию
if help_command is None:
help_command = MyHelpCommand()
# ВАЖНО: tree создаёт сам commands.Bot, мы его не переопределяем
super().__init__(
command_prefix=command_prefix,
intents=intents,
help_command=help_command,
)
# Сохраняем токен и хранилище
self._token: Optional[str] = token
self.storage = storage # type: ignore[assignment]
@@ -65,54 +56,71 @@ class Bot(commands.Bot):
async def setup(self) -> None:
"""
Инициализация бота: логгер, cogs, логирование discord.py.
Инициализация бота: логгер и загрузка cogs.
"""
logger.setup(start=True)
logger.info(text="Настройка бота...", log_type="SYSTEM")
await self.load_cogs()
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s:%(levelname)s:%(name)s: %(message)s",
)
logging.getLogger("discord").setLevel(logging.INFO)
async def load_cogs(self) -> None:
"""
Загрузить все модули cogs.
"""
logger.info(text="Начинаю загрузку cogs...", log_type="COGS")
cogs: list[str] = [
"cogs.events",
"cogs.moderation",
"cogs.blacklist",
"cogs.reminders",
"bot.cogs.events",
"bot.cogs.moderation",
"bot.cogs.blacklist",
"bot.cogs.reminders",
"bot.cogs.slash",
"bot.cogs.music",
]
for cog in cogs:
try:
await self.load_extension(cog)
logger.info(f"Загружен cog: {cog}", log_type="COGS")
logger.info(text=f"Загружен cog: {cog}", log_type="COGS")
except Exception as e:
logger.error(f"Ошибка загрузки {cog}: {e}", log_type="COGS")
logger.error(text=f"Ошибка загрузки {cog}: {e!r}", log_type="COGS")
async def setup_hook(self) -> None:
"""
Хук discord.py 2.x: вызывается перед подключением к Gateway.
Здесь синхронизируем slash-команды.
"""
await self.tree.sync()
logger.info(text="Slash-команды синхронизированы", log_type="SYSTEM")
async def start_bot(self, token: Optional[str] = None) -> None:
"""
Запуск бота с использованием сохранённого токена или переданного.
:param token: Токен бота (если None — используется self.token).
"""
use_token: Optional[str] = token or self.token
if not use_token:
error: str = "BOT_TOKEN не задан (ни в конструкторе, ни в settings)"
logger.error(error)
logger.error(text=error, log_type="START")
raise ValueError(error)
logger.info(text="Запуск бота...", log_type="START")
await self.start(use_token)
@staticmethod
async def on_command(ctx: commands.Context) -> None:
"""
Глобальное логирование всех вызванных префиксных команд.
"""
logger.info(
text=(
f"Команда: {ctx.command} | Автор: {ctx.author} | "
f"Гильдия: {ctx.guild} | Сообщение: {ctx.message.content}"
),
log_type="COMMAND",
user=str(ctx.author),
)
# Глобальный экземпляр — МОЖНО ПЕРЕДАВАТЬ token/prefix ПРЯМО ЗДЕСЬ
discbot: Bot = Bot(
token=settings.BOT_TOKEN, # кастомный токен
prefix=settings.PREFIX, # кастомный префикс
token=settings.BOT_TOKEN,
prefix=settings.PREFIX,
)

View File

@@ -2,3 +2,4 @@ from .events import *
from .blacklist import *
from .reminders import *
from .moderation import *
from .music import *

View File

@@ -1,20 +1,23 @@
from discord.ext import commands
from discord.ext.commands import Cog, Bot, command, Context
import discord
from ..storage import storage
from .moderation import is_admin
class Blacklist(commands.Cog):
class Blacklist(Cog):
"""
Cog для управления чёрным списком слов.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
def __init__(self, bot: Bot) -> None:
self.bot: Bot = bot
@commands.command()
@discord.app_commands.command(
name="blacklist_show",
description="Показать текущий чёрный список слов",
)
@is_admin()
async def blacklist_show(self, ctx: commands.Context) -> None:
async def blacklist_show(self, ctx: Context) -> None:
"""
Показать текущий чёрный список слов.
@@ -25,9 +28,12 @@ class Blacklist(commands.Cog):
else:
await ctx.send("Чёрный список:\n" + ", ".join(storage.blacklist))
@commands.command()
@discord.app_commands.command(
name="blacklist_add",
description="Добавить слово в чёрный список",
)
@is_admin()
async def blacklist_add(self, ctx: commands.Context, *, word: str) -> None:
async def blacklist_add(self, ctx: Context, *, word: str) -> None:
"""
Добавить слово в чёрный список.
@@ -43,9 +49,12 @@ class Blacklist(commands.Cog):
storage.save_blacklist()
await ctx.send(f"Слово `{word_lower}` добавлено в чёрный список.")
@commands.command()
@discord.app_commands.command(
name="blacklist_remove",
description="Удалить слово из чёрного списка",
)
@is_admin()
async def blacklist_remove(self, ctx: commands.Context, *, word: str) -> None:
async def blacklist_remove(self, ctx: Context, *, word: str) -> None:
"""
Удалить слово из чёрного списка.
@@ -62,5 +71,5 @@ class Blacklist(commands.Cog):
await ctx.send(f"Слово `{word_lower}` удалено из чёрного списка.")
async def setup(bot: commands.Bot) -> None:
async def setup(bot: Bot) -> None:
await bot.add_cog(Blacklist(bot))

View File

@@ -1,34 +1,98 @@
import datetime
from datetime import datetime
import discord
from discord.ext import commands, tasks
from discord.ext import tasks
from discord.ext.commands import Bot, Cog, Context, CommandError
from discord.utils import get
from configs import settings
from middleware import logger
from middleware.loggers import logger
from ..storage import storage, Reminder
class Events(commands.Cog):
class Events(Cog):
"""
Cog с обработчиками событий и фоновой задачей напоминаний.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
def __init__(self, bot: Bot) -> None:
self.bot: Bot = bot
self.check_reminders.start()
@commands.Cog.listener()
@Cog.listener()
async def on_presence_update(self, before: discord.Member, after: discord.Member) -> None:
"""
Следим за конкретным пользователем и меняем ник на '[AFK] Ник' при уходе в Idle,
а при возвращении восстанавливаем оригинальный ник.
"""
afk_nickname = getattr(settings, "AFK_NICKNAME", "AFK")
# если статус не изменился — выходим
if before.status == after.status:
return
# словарь для хранения оригинального ника
if not hasattr(self, 'original_nick'):
self.original_nick = {}
# пользователь ушёл в AFK/Idle
if after.status == discord.Status.idle:
# если уже AFK, ничего не делаем
if after.nick and after.nick.startswith(afk_nickname):
return
# сохраняем оригинальный ник
self.original_nick[after.id] = after.nick or after.name
try:
await after.edit(nick=afk_nickname)
logger.info(
text=f"{after} ушёл в AFK, ник изменён",
log_type="PRESENCE",
user=str(after),
)
except discord.Forbidden:
logger.warning(
text=f"Нет прав изменить ник {after}",
log_type="PRESENCE",
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка смены ника {after}: {e!r}",
log_type="PRESENCE",
)
# пользователь вернулся из AFK
elif before.status == discord.Status.idle and after.status != discord.Status.idle:
original = self.original_nick.get(after.id)
if original:
try:
await after.edit(nick=original)
logger.info(
text=f"{after} вернулся из AFK, ник восстановлен",
log_type="PRESENCE",
user=str(after),
)
del self.original_nick[after.id] # убираем из словаря
except discord.Forbidden:
logger.warning(
text=f"Нет прав вернуть ник {after}",
log_type="PRESENCE",
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка возврата ника {after}: {e!r}",
log_type="PRESENCE",
)
@Cog.listener()
async def on_ready(self) -> None:
"""
Событие запуска бота.
Загружает данные и создаёт необходимые роли.
"""
logger.info(text=f"Бот запущен как {self.bot.user}")
logger.info(text=f"Бот запущен как {self.bot.user}", log_type="SYSTEM")
storage.load_all()
await self.ensure_roles_exist()
@commands.Cog.listener()
@Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
"""
Событие вступления нового участника на сервер.
@@ -37,20 +101,31 @@ class Events(commands.Cog):
"""
new_member_role: discord.Role | None = get(member.guild.roles, name="New Member")
if new_member_role:
try:
await member.add_roles(new_member_role)
channel: discord.abc.MessageableChannel | None = self.bot.get_channel(
settings.WELCOME_CHANNEL_ID
except discord.Forbidden:
logger.warning(
text=f"Нет прав выдать роль New Member пользователю {member}",
log_type="EVENT",
user=str(member),
)
if isinstance(channel, discord.TextChannel):
await channel.send(f"Приветствуем {member.mention} на сервере!")
@commands.Cog.listener()
channel = self.bot.get_channel(settings.WELCOME_CHANNEL_ID)
if isinstance(channel, discord.TextChannel):
try:
await channel.send(f"Приветствуем {member.mention} на сервере!")
except discord.HTTPException as e:
logger.error(
text=f"Не удалось отправить приветствие для {member}: {e!r}",
log_type="EVENT",
user=str(member),
)
@Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""
Событие получения сообщения. Проверяет чёрный список слов.
:param message: Полученное сообщение.
Событие получения сообщения. Проверяет чёрный список слов
и передаёт сообщение обработчику команд.
"""
if message.author.bot or not message.content:
return
@@ -62,11 +137,35 @@ class Events(commands.Cog):
await message.channel.send(
f"{message.author.mention}, ваше сообщение содержит запрещённые слова."
)
except Exception:
pass
logger.info(
text=f"Удалено сообщение с запрещённым словом от {message.author}: {message.content!r}",
log_type="BLACKLIST",
user=str(message.author),
)
except discord.Forbidden:
logger.warning(
text=f"Нет прав удалить сообщение {message.author}: {message.content!r}",
log_type="BLACKLIST",
user=str(message.author),
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка при удалении сообщения {message.author}: {e!r}",
log_type="BLACKLIST",
user=str(message.author),
)
return
await self.bot.process_commands(message)
@Cog.listener()
async def on_command_error(self, ctx: Context, error: CommandError) -> None:
"""
Логирование ошибок команд.
"""
logger.error(
text=f"Ошибка команды: {ctx.command} | Автор: {ctx.author} | Ошибка: {error!r}",
log_type="COMMAND",
user=str(ctx.author),
)
async def ensure_roles_exist(self) -> None:
"""
@@ -78,18 +177,40 @@ class Events(commands.Cog):
try:
muted_role = await guild.create_role(name="Muted")
for channel in guild.channels:
try:
await channel.set_permissions(
muted_role, send_messages=False, speak=False
)
except Exception:
pass
except discord.Forbidden:
logger.warning(
text=f"Нет прав настроить права для канала {channel} в {guild}",
log_type="ROLES",
)
except discord.Forbidden:
logger.warning(
text=f"Нет прав создать роль Muted в {guild}",
log_type="ROLES",
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка при создании роли Muted в {guild}: {e!r}",
log_type="ROLES",
)
new_member_role: discord.Role | None = get(guild.roles, name="New Member")
if new_member_role is None:
try:
await guild.create_role(name="New Member")
except Exception:
pass
except discord.Forbidden:
logger.warning(
text=f"Нет прав создать роль New Member в {guild}",
log_type="ROLES",
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка при создании роли New Member в {guild}: {e!r}",
log_type="ROLES",
)
@tasks.loop(seconds=30)
async def check_reminders(self) -> None:
@@ -97,19 +218,32 @@ class Events(commands.Cog):
Фоновая задача, которая каждые 30 секунд проверяет напоминания
и отправляет просроченные.
"""
now: float = datetime.datetime.now().timestamp()
now: float = datetime.now().timestamp()
to_remove: list[Reminder] = []
for rem in storage.reminders:
for rem in list(storage.reminders):
if rem.time <= now:
channel = self.bot.get_channel(rem.channel_id)
if isinstance(channel, discord.TextChannel):
try:
await channel.send(f"{rem.user_mention} Напоминание: {rem.text}")
logger.info(
text=f"Отправлено напоминание пользователю {rem.user_mention}: {rem.text!r}",
log_type="REMINDER",
)
except discord.HTTPException as e:
logger.error(
text=f"Ошибка при отправке напоминания в канал {channel.id}: {e!r}",
log_type="REMINDER",
)
to_remove.append(rem)
if to_remove:
for rem in to_remove:
try:
storage.reminders.remove(rem)
except ValueError:
pass
storage.save_reminders()
@check_reminders.before_loop
@@ -120,7 +254,7 @@ class Events(commands.Cog):
await self.bot.wait_until_ready()
async def setup(bot: commands.Bot) -> None:
async def setup(bot: Bot) -> None:
"""
Функция для загрузки Cog.

View File

@@ -1,11 +1,11 @@
from __future__ import annotations
from typing import Callable, Awaitable, Optional
import datetime
from datetime import datetime
import discord
from discord.ext import commands
from discord.app_commands.checks import has_permissions
from discord.ext.commands import Bot, Context, Cog, check, command
from discord.utils import get
from configs import settings
@@ -13,7 +13,7 @@ from ..storage import storage
# Тип предиката для check (для читаемости, но не обязателен)
CheckPredicate = Callable[[commands.Context], Awaitable[bool]]
CheckPredicate = Callable[[Context], Awaitable[bool]]
def is_admin():
@@ -23,7 +23,7 @@ def is_admin():
либо у пользователя есть флаг администратора сервера.
"""
async def predicate(ctx: commands.Context) -> bool:
async def predicate(ctx: Context) -> bool:
author = ctx.author
if not isinstance(author, discord.Member):
return False
@@ -31,10 +31,10 @@ def is_admin():
admin_role = get(author.roles, name=settings.ADMIN_ROLE_NAME)
return bool(admin_role) or author.guild_permissions.administrator
return commands.check(predicate)
return check(predicate)
def require_guild(ctx: commands.Context) -> Optional[discord.Guild]:
def require_guild(ctx: Context) -> Optional[discord.Guild]:
"""
Безопасно получить guild из контекста.
@@ -43,41 +43,27 @@ def require_guild(ctx: commands.Context) -> Optional[discord.Guild]:
return ctx.guild
class Moderation(commands.Cog):
class Moderation(Cog):
"""
Cog с модерационными командами:
rules, kick, ban, unban, mute, unmute, warn, warnings, clear.
"""
def __init__(self, bot: commands.Bot) -> None:
def __init__(self, bot: Bot) -> None:
"""
:param bot: Экземпляр бота, к которому привязан cog.
"""
self.bot: commands.Bot = bot
self.bot: Bot = bot
@commands.command()
@is_admin()
async def rules(self, ctx: commands.Context) -> None:
"""
Показать правила сервера.
:param ctx: Контекст команды.
"""
rules_text: str = (
"**Правила сервера:**\n"
"1. Уважайте других участников.\n"
"2. Запрещена реклама и спам.\n"
"3. Не используйте запрещённые слова.\n"
"4. Соблюдайте тематику каналов.\n"
"5. Выполняйте указания модераторов.\n"
@discord.app_commands.command(
name="kick",
description="Исключить участника с сервера",
)
await ctx.send(rules_text)
@commands.command()
@is_admin()
async def kick(
self,
ctx: commands.Context,
ctx: Context,
member: discord.Member,
*,
reason: Optional[str] = None,
@@ -97,15 +83,12 @@ class Moderation(commands.Cog):
except discord.HTTPException:
await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.")
@commands.command()
@discord.app_commands.command(
name="ban",
description="Забанить участника на сервере",
)
@is_admin()
async def ban(
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
async def ban(self,ctx: Context,member: discord.Member,*,reason: Optional[str] = None) -> None:
"""
Забанить участника на сервере.
@@ -113,6 +96,9 @@ class Moderation(commands.Cog):
:param member: Участник для бана.
:param reason: Причина бана.
"""
user_id: str = str(member.id)
storage.user_warnings.pop(user_id,None)
storage.save_warnings()
try:
await member.ban(reason=reason)
await ctx.send(f"{member} был забанен. Причина: {reason}")
@@ -121,9 +107,12 @@ class Moderation(commands.Cog):
except discord.HTTPException:
await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.")
@commands.command()
@commands.has_permissions(ban_members=True)
async def unban(self, ctx: commands.Context, *, member_name: str) -> None:
@discord.app_commands.command(
name="unban",
description="Разбанить пользователя по имени или тегу",
)
@has_permissions(ban_members=True)
async def unban(self, ctx: Context, *, member_name: str) -> None:
"""
Разбанить пользователя по имени или тегу.
@@ -190,11 +179,14 @@ class Moderation(commands.Cog):
msg_lines.append(f"- {user.name}#{user.discriminator}")
await ctx.send("\n".join(msg_lines))
@commands.command()
@discord.app_commands.command(
name="mute",
description="Выдать участнику мут",
)
@is_admin()
async def mute(
self,
ctx: commands.Context,
ctx: Context,
member: discord.Member,
*,
reason: Optional[str] = None,
@@ -224,9 +216,12 @@ class Moderation(commands.Cog):
except discord.HTTPException:
await ctx.send("Не удалось выдать мут из-за ошибки Discord.")
@commands.command()
@discord.app_commands.command(
name="unmute",
description="Снять мут с участника",
)
@is_admin()
async def unmute(self, ctx: commands.Context, member: discord.Member) -> None:
async def unmute(self, ctx: Context, member: discord.Member) -> None:
"""
Снять мут с участника.
@@ -251,11 +246,14 @@ class Moderation(commands.Cog):
except discord.HTTPException:
await ctx.send("Не удалось снять мут из-за ошибки Discord.")
@commands.command()
@discord.app_commands.command(
name="warn",
description="Выдать предупреждение участнику",
)
@is_admin()
async def warn(
self,
ctx: commands.Context,
ctx: Context,
member: discord.Member,
*,
reason: Optional[str] = None,
@@ -267,22 +265,30 @@ class Moderation(commands.Cog):
:param member: Участник.
:param reason: Причина предупреждения.
"""
max_warning= 3
user_id: str = str(member.id)
storage.user_warnings.setdefault(user_id, []).append(
{
"reason": reason or "Без причины",
"date": datetime.datetime.now().isoformat(
"date": datetime.now().isoformat(
sep=" ", timespec="seconds"
),
}
)
storage.save_warnings()
warns_count = len(storage.user_warnings[user_id])
await ctx.send(
f"{member} получил предупреждение. Причина: {reason or 'Без причины'}"
f"{member} получил {warns_count} предупреждение. Причина: {reason or 'Без причины'}. До бана осталось {max_warning-warns_count} предупреждения."
)
@commands.command()
async def warnings(self, ctx: commands.Context, member: discord.Member) -> None:
if warns_count >= max_warning:
await self.ban(ctx,member,reason=f"Превышен лимит предупреждений ({warns_count})")
@discord.app_commands.command(
name="warnings",
description="Показать предупреждения участника",
)
async def warnings(self, ctx: Context, member: discord.Member) -> None:
"""
Показать предупреждения участника.
@@ -300,9 +306,12 @@ class Moderation(commands.Cog):
lines.append(f"{i}. {w['reason']} ({w['date']})")
await ctx.send("\n".join(lines))
@commands.command()
@discord.app_commands.command(
name="clear",
description="Очистить указанное количество сообщений в канале",
)
@is_admin()
async def clear(self, ctx: commands.Context, amount: int) -> None:
async def clear(self, ctx: Context, amount: int) -> None:
"""
Очистить указанное количество сообщений в канале.
@@ -320,7 +329,7 @@ class Moderation(commands.Cog):
)
async def setup(bot: commands.Bot) -> None:
async def setup(bot: Bot) -> None:
"""
Зарегистрировать cog в боте.

219
bot/cogs/music.py Normal file
View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import asyncio
from typing import Dict, List, Any, Optional
from middleware import logger
import discord
from discord.ext import commands
import yt_dlp
COG_TYPE="Music"
# yt-dlp конфиг с поддержкой поиска и node
YTDL_OPTIONS: Dict[str, Any] = {
"format": "bestaudio/best",
"noplaylist": True,
"quiet": True,
"default_search": "ytsearch",
"source_address": "0.0.0.0",
"remote_components": {
"ejs:github": "github"
},
# "js_runtimes": {
# "deno": {'path': "/usr/local/bin/deno"}
# }
}
FFMPEG_OPTIONS: Dict[str, str] = {
"options": "-vn",
"before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
}
ytdl = yt_dlp.YoutubeDL(YTDL_OPTIONS)
class Music(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.queue: Dict[int, List[Dict[str, str]]] = {}
logger.info(text="Инициализация Music", log_type="cog")
@discord.app_commands.command(name="queue", description="Посмотреть очередь")
async def getQueue(self, interaction: discord.Interaction):
safeQueue = "\n".join(
track["title"]
for guild_queue in self.queue.values()
for track in guild_queue
) or "❌ Пустая очередь"
logger.info(text=f"Текущая очередь:\n{safeQueue}", log_type="cog")
await interaction.response.send_message(f"Текущая очередь:\n{safeQueue}")
async def connect_voice(self, interaction: discord.Interaction) -> Optional[discord.VoiceClient]:
if not interaction.user.voice or not interaction.user.voice.channel:
logger.warning(
text=f"Юзер не в голосовом канале.\nЮзер: {interaction.user.voice}. Канал: {interaction.user.voice.channel}",
log_type="cog"
)
await interaction.response.send_message("❌ Вы должны быть в голосовом канале.", ephemeral=True)
return None
voice_client = interaction.guild.voice_client
if voice_client is None:
try:
voice_client = await interaction.user.voice.channel.connect()
except Exception as e:
logger.error(text=f"Не удалось подключиться\nОшибка: {e}", log_type="cog")
await interaction.response.send_message(f"Не удалось подключиться: {e}", ephemeral=True)
return None
return voice_client
async def get_audio(self, query: str, interaction: discord.Interaction) -> Optional[Dict[str, Any]]:
try:
logger.info(
text=f"Поиск по ключевому слову {query}...",
log_type="cog"
)
if query.startswith("http"):
data = ytdl.extract_info(query, download=False);
logger.info(
text=f"Предоставлена ссылка {query}",
log_type="cog"
)
else:
data = ytdl.extract_info(f"ytsearch:{query}", download=False)
if "entries" in data:
title = data["entries"][0]['title']
data = data["entries"][0]
logger.info(
text=f"Найдено {title}",
log_type="cog"
)
else:
logger.warning(
text=f"Ничего не найдено",
log_type="cog"
)
return data
except Exception as e:
logger.error(
text=f"Ошибка при получении аудио {e}",
log_type="cog"
)
await interaction.followup.send(f":x: Ошибка при получении аудио")
return None
async def play_next(self, guild_id: int, channel: discord.TextChannel):
if guild_id not in self.queue or not self.queue[guild_id]:
await channel.send("📭 Очередь пуста.")
return
track = self.queue[guild_id].pop(0)
voice_client = channel.guild.voice_client
if not voice_client:
return
await self.start_track(voice_client, guild_id, channel, track)
@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 = interaction.guild.id
if guild_id not in self.queue:
self.queue[guild_id] = []
await interaction.response.defer()
data = await self.get_audio(query, interaction)
if not data:
await interaction.followup.send("Не удалось найти трек.")
return
stream_url = data["url"]
title = data.get("title", "Неизвестный трек")
if voice_client.is_playing():
self.queue[guild_id].append({"title" : title, "stream_url" : stream_url})
await interaction.followup.send(f" Трек {title} добавлен в очередь.")
return
try:
source = discord.FFmpegOpusAudio(
stream_url,
executable="ffmpeg",
**FFMPEG_OPTIONS
)
except Exception:
await interaction.followup.send("❌ Ошибка воспроизведения.")
return
await interaction.followup.send(f"▶️ Сейчас играет: **{title}**")
def after_playing(error):
logger.info(text=f"Включаем следующий трек...", log_type="cog")
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)
@discord.app_commands.command(name="skip", description="Пропустить текущий трек")
async def skip(self, interaction: discord.Interaction):
logger.info(
text=f"Скип...",
log_type="cog"
)
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 interaction.response.send_message("⏭️ Трек пропущен.")
@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("⏹️ Музыка остановлена.")
@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 start_track(self, voice_client, guild_id, channel, track):
source = discord.FFmpegOpusAudio(
track["stream_url"],
executable="ffmpeg",
**FFMPEG_OPTIONS
)
await channel.send(f"▶️ Сейчас играет: **{track['title']}**")
def after_playing(error):
asyncio.run_coroutine_threadsafe(
self.play_next(guild_id, channel),
self.bot.loop
)
voice_client.play(source, after=after_playing)
async def setup(bot: commands.Bot):
await bot.add_cog(Music(bot))

View File

@@ -1,93 +1,73 @@
from datetime import datetime, timedelta
from typing import Optional
import discord
from discord.ext import commands
from discord import app_commands
from ..storage import storage, Reminder
from .moderation import is_admin
class Reminders(commands.Cog):
"""
Cog для управления напоминаниями: add, list, remove.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
"""Cog для управления напоминаниями: add, list, remove через slash-команды."""
@commands.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: commands.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: commands.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}")
@reminder.command(name="list")
async def reminder_list(self, ctx: commands.Context) -> None:
"""
Показать список активных напоминаний.
:param ctx: Контекст команды.
"""
if not storage.reminders:
await ctx.send("Активных напоминаний нет.")
return
msg: str = "Активные напоминания:\n"
for i, rem in enumerate(storage.reminders, 1):
t_str: str = datetime.fromtimestamp(rem.time).strftime(
"%Y-%m-%d %H:%M:%S"
await interaction.response.send_message(
f"✅ Напоминание добавлено через {minutes} минут: {text}"
)
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: commands.Context, number: int) -> None:
"""
Удалить напоминание по номеру.
:param ctx: Контекст команды.
:param number: Порядковый номер напоминания.
"""
if number <= 0 or number > len(storage.reminders):
await ctx.send("Неверный номер напоминания.")
@reminder_group.command(name="list", description="Показать список активных напоминаний")
@is_admin()
async def reminder_list(self, interaction: discord.Interaction) -> None:
if not storage.reminders:
await interaction.response.send_message("Активных напоминаний нет.", ephemeral=True)
return
removed: Reminder = storage.reminders.pop(number - 1)
msg = "📋 Активные напоминания:\n"
for i, rem in enumerate(storage.reminders, 1):
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 interaction.response.send_message(msg)
@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 interaction.response.send_message("❌ Неверный номер напоминания.", ephemeral=True)
return
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: commands.Bot) -> None:

47
bot/cogs/slash.py Normal file
View File

@@ -0,0 +1,47 @@
import discord
from discord.ext import commands
from middleware.loggers import logger
class Slash(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@discord.app_commands.command(
name="rules",
description="Показать правила сервера",
)
async def rules_slash(self, interaction: discord.Interaction) -> None:
rules_text: str = (
"**Правила сервера:**\n"
"1. Уважайте других участников.\n"
"2. Запрещена реклама и спам.\n"
"3. Не используйте запрещённые слова.\n"
"4. Соблюдайте тематику каналов.\n"
"5. Выполняйте указания модераторов.\n"
)
await interaction.response.send_message(rules_text, ephemeral=False)
logger.info(
text=f"Slash /rules вызван пользователем {interaction.user}",
log_type="COMMAND",
user=str(interaction.user),
)
@discord.app_commands.command(
name="ping",
description="Проверить отклик бота",
)
async def ping_slash(self, interaction: discord.Interaction) -> None:
await interaction.response.send_message("Pong!", ephemeral=True)
logger.info(
text=f"Slash /ping вызван пользователем {interaction.user}",
log_type="COMMAND",
user=str(interaction.user),
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Slash(bot))

View File

@@ -22,6 +22,8 @@ class MyHelpCommand(HelpCommand):
f"`{prefix}warn @пользователь [причина]` — предупреждение\n"
f"`{prefix}warnings @пользователь` — список предупреждений\n"
f"`{prefix}clear <кол-во>` — очистить чат\n"
f"`{prefix}blacklist_show/add/remove` — чёрный список\n"
f"`{prefix}blacklist_show` — показать чёрный список\n"
f"`{prefix}blacklist_add` — добавить слово чёрный список\n"
f"`{prefix}blacklist_remove` — удалить слово из чёрного списока\n"
)
await channel.send(help_text)

View File

@@ -16,14 +16,15 @@ class _Settings(BaseSettings):
)
BOT_TOKEN: Optional[str] = None
PREFIX: Optional[str] = '!'
PREFIX: Optional[str] = '/'
WELCOME_CHANNEL_ID: int = 0
ADMIN_ROLE_NAME: str = "Администратор"
AFK_NICKNAME:str ="AFK"
WARNINGS_FILE: Path = Path("warnings.json")
REMINDERS_FILE: Path = Path("reminders.json")
BLACKLIST_FILE: Path = Path("blacklist.json")
WARNINGS_FILE: Path = Path("data/warnings.json")
REMINDERS_FILE: Path = Path("data/reminders.json")
BLACKLIST_FILE: Path = Path("data/blacklist.json")
LOG_FILE_NAME: Path = Path("bot.log")
LOG_LEVEL: str = "info"
@@ -53,6 +54,13 @@ class _Settings(BaseSettings):
raise ValueError(f"LOG_LEVEL должен быть одним из: {', '.join(allowed)}")
return v.lower()
@field_validator("AFK_NICKNAME")
def validate_afk_nickname(cls, v: str) -> str:
# если пустая строка или None, используем дефолт "AFK"
if not v or not v.strip():
return "AFK"
return v.strip()
@field_validator("WARNINGS_FILE", "REMINDERS_FILE", "BLACKLIST_FILE", "LOG_FILE_NAME", mode="before")
def validate_paths(cls, v) -> Optional[Path]:
return Path(v) if isinstance(v, str) else v

10
main.py
View File

@@ -1,18 +1,18 @@
from asyncio import run
from bot import discbot
from configs import settings
from middleware import logger
from middleware.loggers import logger
async def main() -> None:
"""
Точка входа для асинхронного запуска бота.
"""
logger.setup()
await discbot.start(settings.BOT_TOKEN)
await discbot.start_bot()
logger.setup() # настройка логера
await discbot.setup() # ЗАГРУЗКА COGS + настройка discord-логов
await discbot.start_bot() # запуск бота (внутри возьмёт token из settings)
if __name__ == "__main__":
run(main())

View File

@@ -6,17 +6,18 @@ authors = [
{name = "NotFate"}
]
license = {text = "MIT"}
readme = "README.md"
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)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"