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/ /env
*.session *.session
*.key *.key
*.pem *.pem

View File

@@ -26,23 +26,21 @@ jobs:
with: with:
python-version: "3.13" 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 - name: Install dependencies
run: | 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 - name: Basic import checks
run: | run: |
poetry run python -c "import configs; from bot import Bot; print('Bot class ok')" 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 "from bot import discbot; print('Global bot instance ok')"
- name: Load cogs without running bot - name: Load cogs without running bot
run: | 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" /> <sourceFolder url="file://$MODULE_DIR$/middleware" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </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" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="TemplatesService"> <component name="TemplatesService">

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.13 (Bot)" /> <option name="sdkName" value="Python 3.13 (Bot)" />
</component> </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> </project>

View File

@@ -1,26 +1,47 @@
# Используем официальный образ Python с подходящей версией # ---------- BUILDER ----------
FROM python:3.13-slim 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 RUN pip install poetry
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Копируем файлы Poetry
COPY pyproject.toml poetry.lock* ./ COPY pyproject.toml poetry.lock* ./
# Настраиваем Poetry (не создавать виртуальное окружение внутри контейнера)
RUN poetry config virtualenvs.create false
# Устанавливаем зависимости через Poetry
RUN poetry install --no-interaction --no-ansi --no-root RUN poetry install --no-interaction --no-ansi --no-root
# Копируем все файлы проекта внутрь контейнера # ---------- RUNTIME ----------
COPY . . FROM python:3.13-slim
# Устанавливаем переменную окружения для буферизации
ENV PYTHONUNBUFFERED=1 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"] CMD ["python", "main.py"]

BIN
README.md

Binary file not shown.

View File

@@ -1,5 +1,4 @@
from typing import Optional from typing import Optional
import logging
from discord import Intents from discord import Intents
from discord.ext import commands from discord.ext import commands
@@ -15,8 +14,7 @@ __all__ = ("Bot", "discbot")
class Bot(commands.Bot): class Bot(commands.Bot):
""" """
Основной класс Discord-бота с методами настройки и запуска. Основной класс Discord-бота с методами настройки и запуска.
Поддерживает префиксные и slash-команды.
Поддерживает передачу token, prefix, intents и help_command в конструктор.
""" """
def __init__( def __init__(
@@ -26,33 +24,26 @@ class Bot(commands.Bot):
intents: Optional[Intents] = None, intents: Optional[Intents] = None,
help_command: Optional[commands.HelpCommand] = None, help_command: Optional[commands.HelpCommand] = None,
) -> 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: if intents is None:
intents = Intents.default() intents = Intents.default()
intents.guilds = True intents.guilds = True
intents.message_content = True # Требует включения в Developer Portal intents.message_content = True
intents.members = True intents.members = True
intents.presences = True
intents.voice_states = True
# Префикс по умолчанию
command_prefix: str = prefix or getattr(settings, "PREFIX", "!") command_prefix: str = prefix or getattr(settings, "PREFIX", "!")
# Help-команда по умолчанию
if help_command is None: if help_command is None:
help_command = MyHelpCommand() help_command = MyHelpCommand()
# ВАЖНО: tree создаёт сам commands.Bot, мы его не переопределяем
super().__init__( super().__init__(
command_prefix=command_prefix, command_prefix=command_prefix,
intents=intents, intents=intents,
help_command=help_command, help_command=help_command,
) )
# Сохраняем токен и хранилище
self._token: Optional[str] = token self._token: Optional[str] = token
self.storage = storage # type: ignore[assignment] self.storage = storage # type: ignore[assignment]
@@ -65,54 +56,71 @@ class Bot(commands.Bot):
async def setup(self) -> None: async def setup(self) -> None:
""" """
Инициализация бота: логгер, cogs, логирование discord.py. Инициализация бота: логгер и загрузка cogs.
""" """
logger.setup(start=True) logger.setup(start=True)
logger.info(text="Настройка бота...", log_type="SYSTEM") logger.info(text="Настройка бота...", log_type="SYSTEM")
await self.load_cogs() 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: async def load_cogs(self) -> None:
""" """
Загрузить все модули cogs. Загрузить все модули cogs.
""" """
logger.info(text="Начинаю загрузку cogs...", log_type="COGS")
cogs: list[str] = [ cogs: list[str] = [
"cogs.events", "bot.cogs.events",
"cogs.moderation", "bot.cogs.moderation",
"cogs.blacklist", "bot.cogs.blacklist",
"cogs.reminders", "bot.cogs.reminders",
"bot.cogs.slash",
"bot.cogs.music",
] ]
for cog in cogs: for cog in cogs:
try: try:
await self.load_extension(cog) 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: 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: async def start_bot(self, token: Optional[str] = None) -> None:
""" """
Запуск бота с использованием сохранённого токена или переданного. Запуск бота с использованием сохранённого токена или переданного.
:param token: Токен бота (если None — используется self.token).
""" """
use_token: Optional[str] = token or self.token use_token: Optional[str] = token or self.token
if not use_token: if not use_token:
error: str = "BOT_TOKEN не задан (ни в конструкторе, ни в settings)" error: str = "BOT_TOKEN не задан (ни в конструкторе, ни в settings)"
logger.error(error) logger.error(text=error, log_type="START")
raise ValueError(error) raise ValueError(error)
logger.info(text="Запуск бота...", log_type="START") logger.info(text="Запуск бота...", log_type="START")
await self.start(use_token) 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( discbot: Bot = Bot(
token=settings.BOT_TOKEN, # кастомный токен token=settings.BOT_TOKEN,
prefix=settings.PREFIX, # кастомный префикс prefix=settings.PREFIX,
) )

View File

@@ -2,3 +2,4 @@ from .events import *
from .blacklist import * from .blacklist import *
from .reminders import * from .reminders import *
from .moderation 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 ..storage import storage
from .moderation import is_admin from .moderation import is_admin
class Blacklist(commands.Cog): class Blacklist(Cog):
""" """
Cog для управления чёрным списком слов. Cog для управления чёрным списком слов.
""" """
def __init__(self, bot: commands.Bot) -> None: def __init__(self, bot: Bot) -> None:
self.bot: commands.Bot = bot self.bot: Bot = bot
@commands.command() @discord.app_commands.command(
name="blacklist_show",
description="Показать текущий чёрный список слов",
)
@is_admin() @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: else:
await ctx.send("Чёрный список:\n" + ", ".join(storage.blacklist)) await ctx.send("Чёрный список:\n" + ", ".join(storage.blacklist))
@commands.command() @discord.app_commands.command(
name="blacklist_add",
description="Добавить слово в чёрный список",
)
@is_admin() @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() storage.save_blacklist()
await ctx.send(f"Слово `{word_lower}` добавлено в чёрный список.") await ctx.send(f"Слово `{word_lower}` добавлено в чёрный список.")
@commands.command() @discord.app_commands.command(
name="blacklist_remove",
description="Удалить слово из чёрного списка",
)
@is_admin() @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}` удалено из чёрного списка.") 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)) await bot.add_cog(Blacklist(bot))

View File

@@ -1,34 +1,98 @@
import datetime from datetime import datetime
import discord 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 discord.utils import get
from configs import settings from configs import settings
from middleware import logger from middleware.loggers import logger
from ..storage import storage, Reminder from ..storage import storage, Reminder
class Events(commands.Cog): class Events(Cog):
""" """
Cog с обработчиками событий и фоновой задачей напоминаний. Cog с обработчиками событий и фоновой задачей напоминаний.
""" """
def __init__(self, bot: commands.Bot) -> None: def __init__(self, bot: Bot) -> None:
self.bot: commands.Bot = bot self.bot: Bot = bot
self.check_reminders.start() 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: 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() storage.load_all()
await self.ensure_roles_exist() await self.ensure_roles_exist()
@commands.Cog.listener() @Cog.listener()
async def on_member_join(self, member: discord.Member) -> None: 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") new_member_role: discord.Role | None = get(member.guild.roles, name="New Member")
if new_member_role: if new_member_role:
await member.add_roles(new_member_role) try:
await member.add_roles(new_member_role)
except discord.Forbidden:
logger.warning(
text=f"Нет прав выдать роль New Member пользователю {member}",
log_type="EVENT",
user=str(member),
)
channel: discord.abc.MessageableChannel | None = self.bot.get_channel( channel = self.bot.get_channel(settings.WELCOME_CHANNEL_ID)
settings.WELCOME_CHANNEL_ID
)
if isinstance(channel, discord.TextChannel): if isinstance(channel, discord.TextChannel):
await channel.send(f"Приветствуем {member.mention} на сервере!") 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),
)
@commands.Cog.listener() @Cog.listener()
async def on_message(self, message: discord.Message) -> None: async def on_message(self, message: discord.Message) -> None:
""" """
Событие получения сообщения. Проверяет чёрный список слов. Событие получения сообщения. Проверяет чёрный список слов
и передаёт сообщение обработчику команд.
:param message: Полученное сообщение.
""" """
if message.author.bot or not message.content: if message.author.bot or not message.content:
return return
@@ -62,11 +137,35 @@ class Events(commands.Cog):
await message.channel.send( await message.channel.send(
f"{message.author.mention}, ваше сообщение содержит запрещённые слова." f"{message.author.mention}, ваше сообщение содержит запрещённые слова."
) )
except Exception: logger.info(
pass 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 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: async def ensure_roles_exist(self) -> None:
""" """
@@ -78,18 +177,40 @@ class Events(commands.Cog):
try: try:
muted_role = await guild.create_role(name="Muted") muted_role = await guild.create_role(name="Muted")
for channel in guild.channels: for channel in guild.channels:
await channel.set_permissions( try:
muted_role, send_messages=False, speak=False 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") new_member_role: discord.Role | None = get(guild.roles, name="New Member")
if new_member_role is None: if new_member_role is None:
try: try:
await guild.create_role(name="New Member") await guild.create_role(name="New Member")
except Exception: except discord.Forbidden:
pass 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) @tasks.loop(seconds=30)
async def check_reminders(self) -> None: async def check_reminders(self) -> None:
@@ -97,19 +218,32 @@ class Events(commands.Cog):
Фоновая задача, которая каждые 30 секунд проверяет напоминания Фоновая задача, которая каждые 30 секунд проверяет напоминания
и отправляет просроченные. и отправляет просроченные.
""" """
now: float = datetime.datetime.now().timestamp() now: float = datetime.now().timestamp()
to_remove: list[Reminder] = [] to_remove: list[Reminder] = []
for rem in storage.reminders: for rem in list(storage.reminders):
if rem.time <= now: if rem.time <= now:
channel = self.bot.get_channel(rem.channel_id) channel = self.bot.get_channel(rem.channel_id)
if isinstance(channel, discord.TextChannel): if isinstance(channel, discord.TextChannel):
await channel.send(f"{rem.user_mention} Напоминание: {rem.text}") 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) to_remove.append(rem)
if to_remove: if to_remove:
for rem in to_remove: for rem in to_remove:
storage.reminders.remove(rem) try:
storage.reminders.remove(rem)
except ValueError:
pass
storage.save_reminders() storage.save_reminders()
@check_reminders.before_loop @check_reminders.before_loop
@@ -120,7 +254,7 @@ class Events(commands.Cog):
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
async def setup(bot: commands.Bot) -> None: async def setup(bot: Bot) -> None:
""" """
Функция для загрузки Cog. Функция для загрузки Cog.

View File

@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Awaitable, Optional from typing import Callable, Awaitable, Optional
from datetime import datetime
import datetime
import discord 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 discord.utils import get
from configs import settings from configs import settings
@@ -13,7 +13,7 @@ from ..storage import storage
# Тип предиката для check (для читаемости, но не обязателен) # Тип предиката для check (для читаемости, но не обязателен)
CheckPredicate = Callable[[commands.Context], Awaitable[bool]] CheckPredicate = Callable[[Context], Awaitable[bool]]
def is_admin(): 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 author = ctx.author
if not isinstance(author, discord.Member): if not isinstance(author, discord.Member):
return False return False
@@ -31,10 +31,10 @@ def is_admin():
admin_role = get(author.roles, name=settings.ADMIN_ROLE_NAME) admin_role = get(author.roles, name=settings.ADMIN_ROLE_NAME)
return bool(admin_role) or author.guild_permissions.administrator 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 из контекста. Безопасно получить guild из контекста.
@@ -43,41 +43,27 @@ def require_guild(ctx: commands.Context) -> Optional[discord.Guild]:
return ctx.guild return ctx.guild
class Moderation(commands.Cog): class Moderation(Cog):
""" """
Cog с модерационными командами: Cog с модерационными командами:
rules, kick, ban, unban, mute, unmute, warn, warnings, clear. 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. :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: Контекст команды. @discord.app_commands.command(
""" name="kick",
rules_text: str = ( description="Исключить участника с сервера",
"**Правила сервера:**\n" )
"1. Уважайте других участников.\n"
"2. Запрещена реклама и спам.\n"
"3. Не используйте запрещённые слова.\n"
"4. Соблюдайте тематику каналов.\n"
"5. Выполняйте указания модераторов.\n"
)
await ctx.send(rules_text)
@commands.command()
@is_admin() @is_admin()
async def kick( async def kick(
self, self,
ctx: commands.Context, ctx: Context,
member: discord.Member, member: discord.Member,
*, *,
reason: Optional[str] = None, reason: Optional[str] = None,
@@ -97,15 +83,12 @@ class Moderation(commands.Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.") await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.")
@commands.command() @discord.app_commands.command(
name="ban",
description="Забанить участника на сервере",
)
@is_admin() @is_admin()
async def ban( async def ban(self,ctx: Context,member: discord.Member,*,reason: Optional[str] = None) -> None:
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
""" """
Забанить участника на сервере. Забанить участника на сервере.
@@ -113,6 +96,9 @@ class Moderation(commands.Cog):
:param member: Участник для бана. :param member: Участник для бана.
:param reason: Причина бана. :param reason: Причина бана.
""" """
user_id: str = str(member.id)
storage.user_warnings.pop(user_id,None)
storage.save_warnings()
try: try:
await member.ban(reason=reason) await member.ban(reason=reason)
await ctx.send(f"{member} был забанен. Причина: {reason}") await ctx.send(f"{member} был забанен. Причина: {reason}")
@@ -121,9 +107,12 @@ class Moderation(commands.Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.") await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.")
@commands.command() @discord.app_commands.command(
@commands.has_permissions(ban_members=True) name="unban",
async def unban(self, ctx: commands.Context, *, member_name: str) -> None: 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}") msg_lines.append(f"- {user.name}#{user.discriminator}")
await ctx.send("\n".join(msg_lines)) await ctx.send("\n".join(msg_lines))
@commands.command() @discord.app_commands.command(
name="mute",
description="Выдать участнику мут",
)
@is_admin() @is_admin()
async def mute( async def mute(
self, self,
ctx: commands.Context, ctx: Context,
member: discord.Member, member: discord.Member,
*, *,
reason: Optional[str] = None, reason: Optional[str] = None,
@@ -224,9 +216,12 @@ class Moderation(commands.Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send("Не удалось выдать мут из-за ошибки Discord.") await ctx.send("Не удалось выдать мут из-за ошибки Discord.")
@commands.command() @discord.app_commands.command(
name="unmute",
description="Снять мут с участника",
)
@is_admin() @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: except discord.HTTPException:
await ctx.send("Не удалось снять мут из-за ошибки Discord.") await ctx.send("Не удалось снять мут из-за ошибки Discord.")
@commands.command() @discord.app_commands.command(
name="warn",
description="Выдать предупреждение участнику",
)
@is_admin() @is_admin()
async def warn( async def warn(
self, self,
ctx: commands.Context, ctx: Context,
member: discord.Member, member: discord.Member,
*, *,
reason: Optional[str] = None, reason: Optional[str] = None,
@@ -267,22 +265,30 @@ class Moderation(commands.Cog):
:param member: Участник. :param member: Участник.
:param reason: Причина предупреждения. :param reason: Причина предупреждения.
""" """
max_warning= 3
user_id: str = str(member.id) user_id: str = str(member.id)
storage.user_warnings.setdefault(user_id, []).append( storage.user_warnings.setdefault(user_id, []).append(
{ {
"reason": reason or "Без причины", "reason": reason or "Без причины",
"date": datetime.datetime.now().isoformat( "date": datetime.now().isoformat(
sep=" ", timespec="seconds" sep=" ", timespec="seconds"
), ),
} }
) )
storage.save_warnings() storage.save_warnings()
warns_count = len(storage.user_warnings[user_id])
await ctx.send( await ctx.send(
f"{member} получил предупреждение. Причина: {reason or 'Без причины'}" f"{member} получил {warns_count} предупреждение. Причина: {reason or 'Без причины'}. До бана осталось {max_warning-warns_count} предупреждения."
) )
@commands.command() if warns_count >= max_warning:
async def warnings(self, ctx: commands.Context, member: discord.Member) -> None: 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']})") lines.append(f"{i}. {w['reason']} ({w['date']})")
await ctx.send("\n".join(lines)) await ctx.send("\n".join(lines))
@commands.command() @discord.app_commands.command(
name="clear",
description="Очистить указанное количество сообщений в канале",
)
@is_admin() @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 в боте. Зарегистрировать 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 datetime import datetime, timedelta
from typing import Optional
import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands
from ..storage import storage, Reminder from ..storage import storage, Reminder
from .moderation import is_admin from .moderation import is_admin
class Reminders(commands.Cog): class Reminders(commands.Cog):
""" """Cog для управления напоминаниями: add, list, remove через slash-команды."""
Cog для управления напоминаниями: add, list, remove.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
@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() @is_admin()
async def reminder(self, ctx: commands.Context) -> None: @app_commands.describe(minutes="Через сколько минут сработает напоминание", text="Текст напоминания")
"""
Группа команд напоминаний.
:param ctx: Контекст команды.
"""
if ctx.invoked_subcommand is None:
await ctx.send(
"Используйте `!reminder add <минуты> <текст>`, "
"`!reminder list` или `!reminder remove <номер>`"
)
@reminder.command(name="add")
async def reminder_add( async def reminder_add(
self, ctx: commands.Context, minutes: int, *, text: str self, interaction: discord.Interaction, minutes: int, text: str
) -> None: ) -> None:
"""
Добавить новое напоминание.
:param ctx: Контекст команды.
:param minutes: Через сколько минут сработает напоминание.
:param text: Текст напоминания.
"""
if minutes <= 0: if minutes <= 0:
await ctx.send("Время должно быть положительным числом минут.") await interaction.response.send_message(
"Время должно быть положительным числом минут.", ephemeral=True
)
return return
remind_time: datetime = datetime.now() + timedelta(minutes=minutes) remind_time = datetime.now() + timedelta(minutes=minutes)
storage.reminders.append( storage.reminders.append(
Reminder( Reminder(
time=remind_time.timestamp(), time=remind_time.timestamp(),
channel_id=ctx.channel.id, # type: ignore[assignment] channel_id=interaction.channel.id, # type: ignore
user_mention=ctx.author.mention, # type: ignore[union-attr] user_mention=interaction.user.mention, # type: ignore
text=text, text=text,
) )
) )
storage.save_reminders() storage.save_reminders()
await ctx.send(f"Напоминание добавлено через {minutes} минут: {text}") await interaction.response.send_message(
f"✅ Напоминание добавлено через {minutes} минут: {text}"
)
@reminder.command(name="list") @reminder_group.command(name="list", description="Показать список активных напоминаний")
async def reminder_list(self, ctx: commands.Context) -> None: @is_admin()
""" async def reminder_list(self, interaction: discord.Interaction) -> None:
Показать список активных напоминаний.
:param ctx: Контекст команды.
"""
if not storage.reminders: if not storage.reminders:
await ctx.send("Активных напоминаний нет.") await interaction.response.send_message("Активных напоминаний нет.", ephemeral=True)
return return
msg: str = "Активные напоминания:\n" msg = "📋 Активные напоминания:\n"
for i, rem in enumerate(storage.reminders, 1): for i, rem in enumerate(storage.reminders, 1):
t_str: str = datetime.fromtimestamp(rem.time).strftime( t_str = datetime.fromtimestamp(rem.time).strftime("%Y-%m-%d %H:%M:%S")
"%Y-%m-%d %H:%M:%S"
)
msg += f"{i}. До {t_str}{rem.text} (от {rem.user_mention})\n" msg += f"{i}. До {t_str}{rem.text} (от {rem.user_mention})\n"
await ctx.send(msg)
@reminder.command(name="remove") await interaction.response.send_message(msg)
async def reminder_remove(self, ctx: commands.Context, number: int) -> None:
"""
Удалить напоминание по номеру.
:param ctx: Контекст команды. @reminder_group.command(name="remove", description="Удалить напоминание по номеру")
:param number: Порядковый номер напоминания. @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): if number <= 0 or number > len(storage.reminders):
await ctx.send("Неверный номер напоминания.") await interaction.response.send_message("Неверный номер напоминания.", ephemeral=True)
return return
removed: Reminder = storage.reminders.pop(number - 1) removed = storage.reminders.pop(number - 1)
storage.save_reminders() storage.save_reminders()
await ctx.send(f"Удалено напоминание: {removed.text}") await interaction.response.send_message(f"Удалено напоминание: {removed.text}")
async def setup(bot: commands.Bot) -> None: 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}warn @пользователь [причина]` — предупреждение\n"
f"`{prefix}warnings @пользователь` — список предупреждений\n" f"`{prefix}warnings @пользователь` — список предупреждений\n"
f"`{prefix}clear <кол-во>` — очистить чат\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) await channel.send(help_text)

View File

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

10
main.py
View File

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

View File

@@ -6,17 +6,18 @@ authors = [
{name = "NotFate"} {name = "NotFate"}
] ]
license = {text = "MIT"} license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.11,<4.0" requires-python = ">=3.11,<4.0"
dependencies = [ dependencies = [
"discord (>=2.3.2,<3.0.0)",
"loguru (>=0.7.3,<0.8.0)", "loguru (>=0.7.3,<0.8.0)",
"email-validator (>=2.3.0,<3.0.0)", "email-validator (>=2.3.0,<3.0.0)",
"pydantic (>=2.12.5,<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] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"