Compare commits

...

24 Commits

Author SHA1 Message Date
e9c2c456e0 Сод для модерации
Some checks failed
CI / basic-checks (push) Failing after 1m36s
2025-12-08 16:48:17 +07:00
21a93d3844 Сод для управления напоминаниями 2025-12-08 16:48:03 +07:00
909908c789 Модуль напоминаний 2025-12-08 16:47:47 +07:00
af2350e3fd Сод для управление черным списком 2025-12-08 16:47:19 +07:00
d9e6a81dac Сод обработчик событий 2025-12-08 16:46:58 +07:00
724057a2b7 Улучшенный логгер на loguru 2025-12-08 16:45:50 +07:00
4f5da676b8 Настройка команды help 2025-12-08 16:45:39 +07:00
49ffccb0b7 Основная точка входа 2025-12-08 16:45:20 +07:00
c889c41e07 Основной модуль создания бота 2025-12-08 16:45:14 +07:00
56b09a6de8 Инициализаторы пакетов 2025-12-08 16:45:02 +07:00
7f592493bc Конфигуратор репозитория 2025-12-08 16:44:48 +07:00
7827295637 Валидатор почтовых адресов 2025-12-08 16:44:35 +07:00
8fd6cdba66 Инструкции сборки контейнера 2025-12-08 16:44:21 +07:00
c46bb1cfa5 Стандартное изображение 2025-12-08 16:44:09 +07:00
61e05181ef Настройка локального окружения переменных 2025-12-08 16:43:58 +07:00
64a8346f3b CI-тесты для репозитория 2025-12-08 16:43:42 +07:00
cf00566297 IDE-файлы 2025-12-08 16:43:31 +07:00
8c46c38c18 Валидатор url-ссылок 2025-12-08 16:43:16 +07:00
bd51a5040b Система контроля версий Poetry 2025-12-08 16:42:57 +07:00
e1abd3a0a8 Добавление README.md файла 2025-12-08 16:42:43 +07:00
c0ddeb8994 Добавление лицензии 2025-12-08 16:42:30 +07:00
c28e583205 Удаление предыдущего формата проекта 2025-12-08 16:42:19 +07:00
82a48d2e8b Игнорирование файлов для системы версий 2025-12-08 16:42:04 +07:00
17079f9867 Игнорирование файлов для сборки контейнера 2025-12-08 16:41:53 +07:00
31 changed files with 1829 additions and 424 deletions

134
.dockerignore Normal file
View File

@@ -0,0 +1,134 @@
# -------------------------------
# Системные и скрытые каталоги
# -------------------------------
.git/
.gitea/
.hg/
.svn/
.gitignore
.dockerignore
.gitattributes
# -------------------------------
# Виртуальные окружения, кэш и зависимости
# -------------------------------
# Python
.venv/
venv/
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
*.eggs/
*.pytest_cache/
.mypy_cache/
.cache/
# Node.js
node_modules/
npm-debug.log*
yarn-error.log*
.pnp/
.pnp.js
# Ruby
.bundle/
vendor/bundle/
# Java / JVM
target/
*.class
*.jar
*.war
*.ear
# Go
bin/
*.exe
# -------------------------------
# IDE и редакторы
# -------------------------------
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
*.iml
# -------------------------------
# Логи и временные файлы
# -------------------------------
*.log
*.logs
*.log.*
*.logs.*
Logs/
Log/
dist/
build/
tmp/
temp/
*.tmp
*.temp
*.swp
*.swo
# -------------------------------
# Конфиденциальные файлы и настройки
# -------------------------------
.env
env/
*.session
*.key
*.pem
*.crt
*.p12
*.jks
credentials/
secrets/
# -------------------------------
# Документация, тесты и примеры
# -------------------------------
docs/
examples/
tests/
test/
*.test
*.tests
README.md
LICENSE
# -------------------------------
# Базы данных и кэш
# -------------------------------
*.db
*.sqlite
*.sqlite3
*.sqlite-shm
*.sqlite-wal
*.dump
*.sql
*.bak
*.backup
# -------------------------------
# Сборка и артефакты
# -------------------------------
*.o
*.obj
*.dll
*.so
*.dylib
*.out
*.egg
*.wheel
*.pyc
coverage/
.coverage.*
# -------------------------------
# Файлы проекта
# -------------------------------
balance.json

135
.gitattributes vendored Normal file
View File

@@ -0,0 +1,135 @@
# =============================================================================
# Git LFS: большие бинарные файлы, модели, архивы
# =============================================================================
*.7z filter=lfs diff=lfs merge=lfs -text
*.rar filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
*.tar.* filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.xz filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
# ML / Data
*.pt filter=lfs diff=lfs merge=lfs -text
*.pth filter=lfs diff=lfs merge=lfs -text
*.ckpt filter=lfs diff=lfs merge=lfs -text
*.h5 filter=lfs diff=lfs merge=lfs -text
*.joblib filter=lfs diff=lfs merge=lfs -text
*.pkl filter=lfs diff=lfs merge=lfs -text
*.pickle filter=lfs diff=lfs merge=lfs -text
*.mlmodel filter=lfs diff=lfs merge=lfs -text
*.onnx filter=lfs diff=lfs merge=lfs -text
*.tflite filter=lfs diff=lfs merge=lfs -text
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
# Data formats
*.npy filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text
*.parquet filter=lfs diff=lfs merge=lfs -text
*.arrow filter=lfs diff=lfs merge=lfs -text
*.msgpack filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
# =============================================================================
# Автоопределение текста и окончания строк
# =============================================================================
* text=auto eol=lf
# =============================================================================
# Текстовые файлы
# =============================================================================
*.py text
*.pyi text
*.ipynb text
*.html text
*.css text
*.js text
*.json text
*.md text
*.yml text
*.yaml text
*.xml text
*.txt text
*.cfg text
*.toml text
*.ini text
*.env text
*.c text
*.cpp text
*.h text
*.hpp text
*.java text
*.sh text
*.bat text
*.ps1 text
*.go text
*.rs text
# =============================================================================
# Бинарные файлы
# =============================================================================
# Изображения
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.bmp binary
*.webp binary
*.ico binary
*.svg text
# Видео и аудио
*.mp4 binary
*.mov binary
*.avi binary
*.mkv binary
*.mp3 binary
*.wav binary
*.flac binary
# Шрифты
*.eot binary
*.ttf binary
*.woff binary
*.woff2 binary
*.otf binary
# Архивы и пакеты
*.jar binary
*.war binary
*.ear binary
*.egg binary
*.whl binary
# IDE-файлы
*.iml binary
*.sublime-project binary
*.sublime-workspace binary
.idea/** binary
.vscode/** binary
# =============================================================================
# GitHub Linguist: указание языка для статистики
# =============================================================================
*.py linguist-language=Python
*.ipynb linguist-language=Jupyter Notebook
*.html linguist-language=HTML
*.css linguist-language=CSS
*.js linguist-language=JavaScript
#*.json linguist-language=JSON
#*.md linguist-language=Markdown
*.yml linguist-language=YAML
*.yaml linguist-language=YAML
*.c linguist-language=C
*.cpp linguist-language=C++
*.h linguist-language=C
*.hpp linguist-language=C++
*.java linguist-language=Java
*.go linguist-language=Go
*.rs linguist-language=Rust
*.sh linguist-language=Shell
*.bat linguist-language=Batchfile
*.ps1 linguist-language=PowerShell

48
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,48 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
basic-checks:
runs-on: ubuntu-latest
env:
BOT_TOKEN: "TEST_TOKEN"
PREFIX: "!"
WELCOME_CHANNEL_ID: "123456789012345678"
ADMIN_ROLE_NAME: "Администратор"
LOG_LEVEL: "info"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
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
- 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')"
- name: Load cogs without running bot
run: |
poetry run python -c "from bot import discbot; import asyncio; asyncio.run(discbot.setup()); print('Cogs loaded')"

149
.gitignore vendored Normal file
View File

@@ -0,0 +1,149 @@
# ===============================
# Системные и скрытые файлы
# ===============================
.DS_Store
Thumbs.db
*.swp
*.swo
# ===============================
# Python
# ===============================
# Виртуальные окружения
.venv/
venv/
env/
env.bak/
venv.bak/
# Кэш интерпретатора
__pycache__/
*.py[cod]
*$py.class
# Пакеты и сборки
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.eg
*.egg
*.eggs
# Poetry
poetry.lock
.pypoetry/
# Тестирование и отчеты
.coverage
htmlcov/
.tox/
.nox/
.pytest_cache/
.mypy_cache/
test/
tests/
Test/
Tests/
# Логи и базы данных
*.log
*.logs
*.log*
*.log.*
*.logs.*
log/
logs/
# Базы данных и кэш
*.db
*.sqlite
*.sqlite3
*.sqlite-shm
*.sqlite-wal
*.dump
*.sql
*.bak
*.backup
# ===============================
# Node.js / JS
# ===============================
node_modules/
npm-debug.log*
yarn-error.log*
.pnp/
.pnp.js
# ===============================
# Ruby
# ===============================
.bundle/
vendor/bundle/
# ===============================
# Java / JVM
# ===============================
target/
*.class
*.jar
*.war
*.ear
# ===============================
# Go
# ===============================
bin/
*.exe
# ===============================
# IDE и редакторы
# ===============================
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
*.iml
# ===============================
# Конфиденциальные файлы и ключи
# ===============================
.env
env
*.session
*.key
*.pem
*.crt
*.p12
*.jks
credentials/
secrets/
# ===============================
# Сборка и артефакты
# ===============================
*.o
*.obj
*.dll
*.so
*.dylib
*.out
*.wheel
*.pyc
coverage/
.coverage.*
# ===============================
# Файлы проекта
# ===============================
balance.json

26
.idea/Bot.iml generated
View File

@@ -1,10 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (Bot)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<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="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/configs" />
</list>
</option>
</component>
</module>

12
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 (Bot)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (Bot)" project-jdk-type="Python SDK" />
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<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" />
</project>

26
Dockerfile Normal file
View File

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) [2025] [NotFate]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

BIN
README.md Normal file

Binary file not shown.

BIN
assets/photo/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

View File

@@ -1,4 +0,0 @@
2025-12-08 13:08:26,502:WARNING:discord.client: PyNaCl is not installed, voice will NOT be supported
2025-12-08 13:08:26,504:INFO:discord.client: logging in using static token
2025-12-08 14:08:52,657:WARNING:discord.client: PyNaCl is not installed, voice will NOT be supported
2025-12-08 14:08:52,659:INFO:discord.client: logging in using static token

405
bot.py
View File

@@ -1,405 +0,0 @@
import discord
from discord.ext import commands, tasks
from discord.utils import get
import datetime
import logging
import os
import json
# --- Настройки и константы ---
BOT_TOKEN = '11'
WELCOME_CHANNEL_ID = 1342797233250107482 # ID канала для приветствий
ADMIN_ROLE_NAME = "Администратор"
WARNINGS_FILE = "warnings.json"
REMINDERS_FILE = "reminders.json"
BLACKLIST_FILE = "blacklist.json"
# --- Логирование ---
logging.basicConfig(
filename='bot.log',
level=logging.INFO,
format='%(asctime)s:%(levelname)s:%(name)s: %(message)s'
)
# --- Интенты ---
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# --- Глобальные переменные ---
reminders = []
user_warnings = {}
blacklist = []
# --- Хелп команда ---
class MyHelpCommand(commands.HelpCommand):
async def send_bot_help(self, mapping):
channel = self.get_destination()
help_text = (
"**Доступные команды:**\n"
"`!help` — показать это сообщение\n"
"`!rules` — показать правила сервера\n"
"`!reminder add <минуты> <текст>` — добавить напоминание (только для админов)\n"
"`!reminder list` — показать все активные напоминания\n"
"`!reminder remove <номер>` — удалить напоминание\n"
"`!kick @пользователь [причина]` — исключить участника\n"
"`!ban @пользователь [причина]` — забанить участника\n"
"`!unban имя#дискриминатор` — разбанить участника\n"
"`!mute @пользователь [причина]` — заглушить участника\n"
"`!unmute @пользователь` — снять заглушение\n"
"`!warn @пользователь [причина]` — выдать предупреждение\n"
"`!warnings @пользователь` — посмотреть предупреждения\n"
"`!clear <кол-во>` — удалить сообщения\n"
"`!blacklist_show` — показать чёрный список (админ)\n"
"`!blacklist_add <слово>` — добавить слово в чёрный список (админ)\n"
"`!blacklist_remove <слово>` — удалить слово из чёрного списка (админ)\n"
)
await channel.send(help_text)
# --- Инициализация бота ---
bot = commands.Bot(command_prefix='!', intents=intents, help_command=MyHelpCommand())
# --- Функции загрузки и сохранения данных ---
def load_data():
global reminders, user_warnings
if os.path.isfile(WARNINGS_FILE):
with open(WARNINGS_FILE, 'r', encoding='utf-8') as f:
try:
user_warnings.update(json.load(f))
except json.JSONDecodeError:
user_warnings.clear()
if os.path.isfile(REMINDERS_FILE):
with open(REMINDERS_FILE, 'r', encoding='utf-8') as f:
try:
reminders.extend(json.load(f))
except json.JSONDecodeError:
reminders.clear()
def save_warnings():
with open(WARNINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(user_warnings, f, ensure_ascii=False, indent=2)
def save_reminders():
with open(REMINDERS_FILE, 'w', encoding='utf-8') as f:
json.dump(reminders, f, ensure_ascii=False, indent=2)
def load_blacklist_local():
global blacklist
if os.path.isfile(BLACKLIST_FILE):
try:
with open(BLACKLIST_FILE, 'r', encoding='utf-8') as f:
blacklist = json.load(f)
except Exception:
blacklist = []
else:
blacklist = []
def save_blacklist_local():
with open(BLACKLIST_FILE, 'w', encoding='utf-8') as f:
json.dump(blacklist, f, ensure_ascii=False, indent=2)
# --- Проверка и создание ролей ---
async def ensure_roles_exist():
for guild in bot.guilds:
muted_role = get(guild.roles, name="Muted")
if not muted_role:
try:
muted_role = await guild.create_role(name="Muted")
for channel in guild.channels:
await channel.set_permissions(muted_role, send_messages=False, speak=False)
logging.info(f"Создана роль 'Muted' в {guild.name}")
except Exception as e:
logging.error(f"Ошибка при создании роли Muted в {guild.name}: {e}")
new_member_role = get(guild.roles, name="New Member")
if not new_member_role:
try:
new_member_role = await guild.create_role(name="New Member")
logging.info(f"Создана роль 'New Member' в {guild.name}")
except Exception as e:
logging.error(f"Ошибка при создании роли New Member в {guild.name}: {e}")
# --- События ---
@bot.event
async def on_ready():
print(f'Бот запущен как {bot.user}')
if not check_reminders.is_running():
check_reminders.start()
load_data()
load_blacklist_local()
await ensure_roles_exist()
@bot.event
async def on_member_join(member):
role = get(member.guild.roles, name="New Member")
if role:
await member.add_roles(role)
channel = bot.get_channel(WELCOME_CHANNEL_ID)
if channel:
await channel.send(f"Приветствуем {member.mention} на сервере!")
@bot.event
async def on_message(message):
if message.author.bot:
return
# Проверка на запрещённые слова
msg_lower = message.content.lower()
if any(word in msg_lower for word in blacklist):
try:
await message.delete()
await message.channel.send(f"{message.author.mention}, ваше сообщение содержит запрещённые слова.")
logging.info(f"Удалено сообщение с запрещёнными словами от {message.author} в {message.channel}")
except Exception as e:
logging.error(f"Ошибка при удалении сообщения: {e}")
return
await bot.process_commands(message)
# --- Проверка прав администратора ---
def is_admin():
def predicate(ctx):
return get(ctx.author.roles, name=ADMIN_ROLE_NAME) is not None or ctx.author.guild_permissions.administrator
return commands.check(predicate)
# --- Команды модерации и управления ---
@bot.command()
@is_admin()
async def blacklist_show(ctx):
if not blacklist:
await ctx.send("Чёрный список пуст.")
else:
await ctx.send("Чёрный список:\n" + ", ".join(blacklist))
@bot.command()
@is_admin()
async def blacklist_add(ctx, *, word: str):
word = word.lower()
if word in blacklist:
await ctx.send(f"Слово `{word}` уже в чёрном списке.")
else:
blacklist.append(word)
save_blacklist_local()
await ctx.send(f"Слово `{word}` добавлено в чёрный список.")
@bot.command()
@is_admin()
async def blacklist_remove(ctx, *, word: str):
word = word.lower()
if word not in blacklist:
await ctx.send(f"Слово `{word}` отсутствует в чёрном списке.")
else:
blacklist.remove(word)
save_blacklist_local()
await ctx.send(f"Слово `{word}` удалено из чёрного списка.")
@bot.command()
@is_admin()
async def rules(ctx):
rules_text = (
"**Правила сервера:**\n"
"1. Уважайте других участников.\n"
"2. Запрещена реклама и спам.\n"
"3. Не используйте запрещённые слова.\n"
"4. Соблюдайте тематику каналов.\n"
"5. Выполняйте указания модераторов.\n"
)
await ctx.send(rules_text)
@bot.command()
@is_admin()
async def kick(ctx, member: discord.Member, *, reason=None):
try:
await member.kick(reason=reason)
await ctx.send(f"{member} был исключён. Причина: {reason}")
logging.info(f"{member} был исключён администратором {ctx.author}. Причина: {reason}")
except Exception as e:
await ctx.send(f"Не удалось исключить {member}.")
logging.error(f"Ошибка при исключении: {e}")
@bot.command()
@is_admin()
async def ban(ctx, member: discord.Member, *, reason=None):
try:
await member.ban(reason=reason)
await ctx.send(f"{member} был забанен. Причина: {reason}")
logging.info(f"{member} был забанен администратором {ctx.author}. Причина: {reason}")
except Exception as e:
await ctx.send(f"Не удалось забанить {member}.")
logging.error(f"Ошибка при бане: {e}")
@bot.command()
@commands.has_permissions(ban_members=True)
async def unban(ctx, *, member_name):
banned_users = []
async for ban_entry in ctx.guild.bans():
banned_users.append(ban_entry)
# Если указан полный тег с #
if '#' in member_name:
try:
name, discriminator = member_name.split('#')
except ValueError:
await ctx.send("Неверный формат пользователя. Используйте Имя#Тег.")
return
for ban_entry in banned_users:
user = ban_entry.user
if (user.name, user.discriminator) == (name, discriminator):
try:
await ctx.guild.unban(user)
await ctx.send(f"Пользователь {user} разбанен.")
return
except Exception as e:
await ctx.send("Ошибка при разбане.")
logging.error(f"Ошибка при разбане: {e}")
return
await ctx.send(f"Пользователь {member_name} не найден в бан-листе.")
return
# Если указан только имя без тега — ищем все совпадения по имени
matching = [ban_entry.user for ban_entry in banned_users if ban_entry.user.name.lower() == member_name.lower()]
if not matching:
await ctx.send(f"Пользователь с именем `{member_name}` не найден в бан-листе.")
return
if len(matching) == 1:
user = matching[0]
try:
await ctx.guild.unban(user)
await ctx.send(f"Пользователь {user} разбанен.")
except Exception as e:
await ctx.send("Ошибка при разбане.")
logging.error(f"Ошибка при разбане: {e}")
return
# Если совпадений несколько — выводим список для выбора
msg = "Найдено несколько пользователей с таким именем. Укажите полный тег для разбанивания:\n"
for user in matching:
msg += f"- {user.name}#{user.discriminator}\n"
await ctx.send(msg)
@bot.command()
@is_admin()
async def mute(ctx, member: discord.Member, *, reason=None):
muted_role = get(ctx.guild.roles, name="Muted")
if not muted_role:
await ctx.send("Роль Muted не найдена.")
return
try:
await member.add_roles(muted_role)
await ctx.send(f"{member} заглушен. Причина: {reason}")
except Exception as e:
await ctx.send("Не удалось выдать мут.")
logging.error(f"Ошибка при муте: {e}")
@bot.command()
@is_admin()
async def unmute(ctx, member: discord.Member):
muted_role = get(ctx.guild.roles, name="Muted")
if not muted_role:
await ctx.send("Роль Muted не найдена.")
return
try:
await member.remove_roles(muted_role)
await ctx.send(f"С мутом снято с {member}.")
except Exception as e:
await ctx.send("Не удалось снять мут.")
logging.error(f"Ошибка при снятии мута: {e}")
@bot.command()
@is_admin()
async def warn(ctx, member: discord.Member, *, reason=None):
user_id = str(member.id)
if user_id not in user_warnings:
user_warnings[user_id] = []
user_warnings[user_id].append({"reason": reason or "Без причины", "date": str(datetime.datetime.now())})
save_warnings()
await ctx.send(f"{member} получил предупреждение. Причина: {reason or 'Без причины'}")
@bot.command()
async def warnings(ctx, member: discord.Member):
user_id = str(member.id)
warns = user_warnings.get(user_id, [])
if not warns:
await ctx.send(f"У пользователя {member} нет предупреждений.")
return
msg = f"Предупреждения пользователя {member}:\n"
for i, w in enumerate(warns, 1):
msg += f"{i}. {w['reason']} ({w['date']})\n"
await ctx.send(msg)
@bot.command()
@is_admin()
async def clear(ctx, amount: int):
if amount <= 0:
await ctx.send("Количество должно быть положительным числом.")
return
deleted = await ctx.channel.purge(limit=amount + 1)
await ctx.send(f"Удалено сообщений: {len(deleted)-1}", delete_after=5)
# --- Команды напоминаний ---
@bot.group()
@is_admin()
async def reminder(ctx):
if ctx.invoked_subcommand is None:
await ctx.send("Используйте `!reminder add <минуты> <текст>`, `!reminder list` или `!reminder remove <номер>`")
@reminder.command(name="add")
async def reminder_add(ctx, minutes: int, *, text: str):
if minutes <= 0:
await ctx.send("Время должно быть положительным числом минут.")
return
remind_time = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
reminders.append({
"time": remind_time.timestamp(),
"channel_id": ctx.channel.id,
"user_mention": ctx.author.mention,
"text": text
})
save_reminders()
await ctx.send(f"Напоминание добавлено через {minutes} минут: {text}")
@reminder.command(name="list")
async def reminder_list(ctx):
if not reminders:
await ctx.send("Активных напоминаний нет.")
return
msg = "Активные напоминания:\n"
for i, rem in enumerate(reminders, 1):
t = datetime.datetime.fromtimestamp(rem["time"]).strftime("%Y-%m-%d %H:%M:%S")
msg += f"{i}. Через {t}{rem['text']} (от {rem['user_mention']})\n"
await ctx.send(msg)
@reminder.command(name="remove")
async def reminder_remove(ctx, number: int):
if number <= 0 or number > len(reminders):
await ctx.send("Неверный номер напоминания.")
return
removed = reminders.pop(number - 1)
save_reminders()
await ctx.send(f"Удалено напоминание: {removed['text']}")
# --- Фоновая задача проверки напоминаний ---
@tasks.loop(seconds=30)
async def check_reminders():
now = datetime.datetime.now().timestamp()
to_remove = []
for rem in reminders:
if rem['time'] <= now:
channel = bot.get_channel(rem['channel_id'])
if channel:
await channel.send(f"{rem['user_mention']} Напоминание: {rem['text']}")
to_remove.append(rem)
for rem in to_remove:
reminders.remove(rem)
if to_remove:
save_reminders()
# --- Запуск бота ---
bot.run(BOT_TOKEN)

3
bot/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .cogs import *
from .bot import *
from .storage import *

118
bot/bot.py Normal file
View File

@@ -0,0 +1,118 @@
from typing import Optional
import logging
from discord import Intents
from discord.ext import commands
from configs import settings
from .help import MyHelpCommand
from middleware.loggers import logger
from .storage import storage
__all__ = ("Bot", "discbot")
class Bot(commands.Bot):
"""
Основной класс Discord-бота с методами настройки и запуска.
Поддерживает передачу token, prefix, intents и help_command в конструктор.
"""
def __init__(
self,
token: Optional[str] = None,
prefix: Optional[str] = None,
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.members = True
# Префикс по умолчанию
command_prefix: str = prefix or getattr(settings, "PREFIX", "!")
# Help-команда по умолчанию
if help_command is None:
help_command = MyHelpCommand()
super().__init__(
command_prefix=command_prefix,
intents=intents,
help_command=help_command,
)
# Сохраняем токен и хранилище
self._token: Optional[str] = token
self.storage = storage # type: ignore[assignment]
@property
def token(self) -> Optional[str]:
"""
Токен бота: сначала из конструктора, затем из settings.
"""
return self._token or settings.BOT_TOKEN
async def setup(self) -> None:
"""
Инициализация бота: логгер, cogs, логирование discord.py.
"""
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.
"""
cogs: list[str] = [
"cogs.events",
"cogs.moderation",
"cogs.blacklist",
"cogs.reminders",
]
for cog in cogs:
try:
await self.load_extension(cog)
logger.info(f"Загружен cog: {cog}", log_type="COGS")
except Exception as e:
logger.error(f"Ошибка загрузки {cog}: {e}", log_type="COGS")
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)
raise ValueError(error)
logger.info(text="Запуск бота...", log_type="START")
await self.start(use_token)
# Глобальный экземпляр — МОЖНО ПЕРЕДАВАТЬ token/prefix ПРЯМО ЗДЕСЬ
discbot: Bot = Bot(
token=settings.BOT_TOKEN, # кастомный токен
prefix=settings.PREFIX, # кастомный префикс
)

4
bot/cogs/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .events import *
from .blacklist import *
from .reminders import *
from .moderation import *

66
bot/cogs/blacklist.py Normal file
View File

@@ -0,0 +1,66 @@
from discord.ext import commands
from ..storage import storage
from .moderation import is_admin
class Blacklist(commands.Cog):
"""
Cog для управления чёрным списком слов.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
@commands.command()
@is_admin()
async def blacklist_show(self, ctx: commands.Context) -> None:
"""
Показать текущий чёрный список слов.
:param ctx: Контекст команды.
"""
if not storage.blacklist:
await ctx.send("Чёрный список пуст.")
else:
await ctx.send("Чёрный список:\n" + ", ".join(storage.blacklist))
@commands.command()
@is_admin()
async def blacklist_add(self, ctx: commands.Context, *, word: str) -> None:
"""
Добавить слово в чёрный список.
:param ctx: Контекст команды.
:param word: Слово для добавления.
"""
word_lower: str = word.lower()
if word_lower in storage.blacklist:
await ctx.send(f"Слово `{word_lower}` уже в чёрном списке.")
return
storage.blacklist.append(word_lower)
storage.save_blacklist()
await ctx.send(f"Слово `{word_lower}` добавлено в чёрный список.")
@commands.command()
@is_admin()
async def blacklist_remove(self, ctx: commands.Context, *, word: str) -> None:
"""
Удалить слово из чёрного списка.
:param ctx: Контекст команды.
:param word: Слово для удаления.
"""
word_lower: str = word.lower()
if word_lower not in storage.blacklist:
await ctx.send(f"Слово `{word_lower}` отсутствует в чёрном списке.")
return
storage.blacklist.remove(word_lower)
storage.save_blacklist()
await ctx.send(f"Слово `{word_lower}` удалено из чёрного списка.")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Blacklist(bot))

129
bot/cogs/events.py Normal file
View File

@@ -0,0 +1,129 @@
import datetime
import discord
from discord.ext import commands, tasks
from discord.utils import get
from configs import settings
from middleware import logger
from ..storage import storage, Reminder
class Events(commands.Cog):
"""
Cog с обработчиками событий и фоновой задачей напоминаний.
"""
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.check_reminders.start()
@commands.Cog.listener()
async def on_ready(self) -> None:
"""
Событие запуска бота.
Загружает данные и создаёт необходимые роли.
"""
logger.info(text=f"Бот запущен как {self.bot.user}")
storage.load_all()
await self.ensure_roles_exist()
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
"""
Событие вступления нового участника на сервер.
:param member: Новый участник.
"""
new_member_role: discord.Role | None = get(member.guild.roles, name="New Member")
if new_member_role:
await member.add_roles(new_member_role)
channel: discord.abc.MessageableChannel | None = self.bot.get_channel(
settings.WELCOME_CHANNEL_ID
)
if isinstance(channel, discord.TextChannel):
await channel.send(f"Приветствуем {member.mention} на сервере!")
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""
Событие получения сообщения. Проверяет чёрный список слов.
:param message: Полученное сообщение.
"""
if message.author.bot or not message.content:
return
msg_lower: str = message.content.lower()
if any(word in msg_lower for word in storage.blacklist):
try:
await message.delete()
await message.channel.send(
f"{message.author.mention}, ваше сообщение содержит запрещённые слова."
)
except Exception:
pass
return
await self.bot.process_commands(message)
async def ensure_roles_exist(self) -> None:
"""
Проверяет наличие ролей Muted и New Member и создаёт их при необходимости.
"""
for guild in self.bot.guilds:
muted_role: discord.Role | None = get(guild.roles, name="Muted")
if muted_role is None:
try:
muted_role = await guild.create_role(name="Muted")
for channel in guild.channels:
await channel.set_permissions(
muted_role, send_messages=False, speak=False
)
except Exception:
pass
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
@tasks.loop(seconds=30)
async def check_reminders(self) -> None:
"""
Фоновая задача, которая каждые 30 секунд проверяет напоминания
и отправляет просроченные.
"""
now: float = datetime.datetime.now().timestamp()
to_remove: list[Reminder] = []
for rem in storage.reminders:
if rem.time <= now:
channel = self.bot.get_channel(rem.channel_id)
if isinstance(channel, discord.TextChannel):
await channel.send(f"{rem.user_mention} Напоминание: {rem.text}")
to_remove.append(rem)
if to_remove:
for rem in to_remove:
storage.reminders.remove(rem)
storage.save_reminders()
@check_reminders.before_loop
async def before_check_reminders(self) -> None:
"""
Ожидание готовности бота перед стартом фоновой задачи.
"""
await self.bot.wait_until_ready()
async def setup(bot: commands.Bot) -> None:
"""
Функция для загрузки Cog.
:param bot: Экземпляр бота.
"""
await bot.add_cog(Events(bot))

329
bot/cogs/moderation.py Normal file
View File

@@ -0,0 +1,329 @@
from __future__ import annotations
from typing import Callable, Awaitable, Optional
import datetime
import discord
from discord.ext import commands
from discord.utils import get
from configs import settings
from ..storage import storage
# Тип предиката для check (для читаемости, но не обязателен)
CheckPredicate = Callable[[commands.Context], Awaitable[bool]]
def is_admin():
"""
Создаёт декоратор проверки прав администратора:
либо есть роль с именем settings.ADMIN_ROLE_NAME,
либо у пользователя есть флаг администратора сервера.
"""
async def predicate(ctx: commands.Context) -> bool:
author = ctx.author
if not isinstance(author, discord.Member):
return False
admin_role = get(author.roles, name=settings.ADMIN_ROLE_NAME)
return bool(admin_role) or author.guild_permissions.administrator
return commands.check(predicate)
def require_guild(ctx: commands.Context) -> Optional[discord.Guild]:
"""
Безопасно получить guild из контекста.
Если команда вызвана не на сервере (в ЛС), возвращает None.
"""
return ctx.guild
class Moderation(commands.Cog):
"""
Cog с модерационными командами:
rules, kick, ban, unban, mute, unmute, warn, warnings, clear.
"""
def __init__(self, bot: commands.Bot) -> None:
"""
:param bot: Экземпляр бота, к которому привязан cog.
"""
self.bot: commands.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"
)
await ctx.send(rules_text)
@commands.command()
@is_admin()
async def kick(
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
"""
Исключить участника с сервера.
:param ctx: Контекст команды.
:param member: Участник для исключения.
:param reason: Причина исключения.
"""
try:
await member.kick(reason=reason)
await ctx.send(f"{member} был исключён. Причина: {reason}")
except discord.Forbidden:
await ctx.send("Недостаточно прав для исключения этого участника.")
except discord.HTTPException:
await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.")
@commands.command()
@is_admin()
async def ban(
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
"""
Забанить участника на сервере.
:param ctx: Контекст команды.
:param member: Участник для бана.
:param reason: Причина бана.
"""
try:
await member.ban(reason=reason)
await ctx.send(f"{member} был забанен. Причина: {reason}")
except discord.Forbidden:
await ctx.send("Недостаточно прав для бана этого участника.")
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:
"""
Разбанить пользователя по имени или тегу.
:param ctx: Контекст команды.
:param member_name: Имя или имя#дискриминатор.
"""
guild = require_guild(ctx)
if guild is None:
await ctx.send("Команду можно использовать только на сервере.")
return
banned_users: list[discord.guild.BanEntry] = [
ban_entry async for ban_entry in guild.bans()
]
# Вариант с полным тегом
if "#" in member_name:
try:
name, discriminator = member_name.split("#", maxsplit=1)
except ValueError:
await ctx.send("Неверный формат пользователя. Используйте Имя#Тег.")
return
for ban_entry in banned_users:
user = ban_entry.user
if (user.name, user.discriminator) == (name, discriminator):
try:
await guild.unban(user)
await ctx.send(f"Пользователь {user} разбанен.")
except discord.HTTPException:
await ctx.send("Ошибка при разбане пользователя.")
return
await ctx.send(f"Пользователь {member_name} не найден в бан-листе.")
return
# Вариант только с именем
matching = [
ban_entry.user
for ban_entry in banned_users
if ban_entry.user.name.lower() == member_name.lower()
]
if not matching:
await ctx.send(
f"Пользователь с именем `{member_name}` не найден в бан-листе."
)
return
if len(matching) == 1:
user = matching[0]
try:
await guild.unban(user)
await ctx.send(f"Пользователь {user} разбанен.")
except discord.HTTPException:
await ctx.send("Ошибка при разбане пользователя.")
return
msg_lines: list[str] = [
"Найдено несколько пользователей с таким именем. "
"Укажите полный тег для разбанивания:",
]
for user in matching:
msg_lines.append(f"- {user.name}#{user.discriminator}")
await ctx.send("\n".join(msg_lines))
@commands.command()
@is_admin()
async def mute(
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
"""
Выдать участнику мут (роль Muted).
:param ctx: Контекст команды.
:param member: Участник, которому выдаётся мут.
:param reason: Причина мута.
"""
guild = require_guild(ctx)
if guild is None:
await ctx.send("Команду можно использовать только на сервере.")
return
muted_role: Optional[discord.Role] = get(guild.roles, name="Muted")
if muted_role is None:
await ctx.send("Роль Muted не найдена.")
return
try:
await member.add_roles(muted_role, reason=reason)
await ctx.send(f"{member} заглушен. Причина: {reason}")
except discord.Forbidden:
await ctx.send("Недостаточно прав для выдачи мута.")
except discord.HTTPException:
await ctx.send("Не удалось выдать мут из-за ошибки Discord.")
@commands.command()
@is_admin()
async def unmute(self, ctx: commands.Context, member: discord.Member) -> None:
"""
Снять мут с участника.
:param ctx: Контекст команды.
:param member: Участник, с которого снимается мут.
"""
guild = require_guild(ctx)
if guild is None:
await ctx.send("Команду можно использовать только на сервере.")
return
muted_role: Optional[discord.Role] = get(guild.roles, name="Muted")
if muted_role is None:
await ctx.send("Роль Muted не найдена.")
return
try:
await member.remove_roles(muted_role)
await ctx.send(f"Мут снят с {member}.")
except discord.Forbidden:
await ctx.send("Недостаточно прав для снятия мута.")
except discord.HTTPException:
await ctx.send("Не удалось снять мут из-за ошибки Discord.")
@commands.command()
@is_admin()
async def warn(
self,
ctx: commands.Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
"""
Выдать предупреждение участнику.
:param ctx: Контекст команды.
:param member: Участник.
:param reason: Причина предупреждения.
"""
user_id: str = str(member.id)
storage.user_warnings.setdefault(user_id, []).append(
{
"reason": reason or "Без причины",
"date": datetime.datetime.now().isoformat(
sep=" ", timespec="seconds"
),
}
)
storage.save_warnings()
await ctx.send(
f"{member} получил предупреждение. Причина: {reason or 'Без причины'}"
)
@commands.command()
async def warnings(self, ctx: commands.Context, member: discord.Member) -> None:
"""
Показать предупреждения участника.
:param ctx: Контекст команды.
:param member: Участник.
"""
user_id: str = str(member.id)
warns = storage.user_warnings.get(user_id, [])
if not warns:
await ctx.send(f"У пользователя {member} нет предупреждений.")
return
lines: list[str] = [f"Предупреждения пользователя {member}:"]
for i, w in enumerate(warns, 1):
lines.append(f"{i}. {w['reason']} ({w['date']})")
await ctx.send("\n".join(lines))
@commands.command()
@is_admin()
async def clear(self, ctx: commands.Context, amount: int) -> None:
"""
Очистить указанное количество сообщений в канале.
:param ctx: Контекст команды.
:param amount: Количество сообщений для удаления.
"""
if amount <= 0:
await ctx.send("Количество должно быть положительным числом.")
return
deleted = await ctx.channel.purge(limit=amount + 1)
await ctx.send(
f"Удалено сообщений: {len(deleted) - 1}",
delete_after=5,
)
async def setup(bot: commands.Bot) -> None:
"""
Зарегистрировать cog в боте.
:param bot: Экземпляр бота.
"""
await bot.add_cog(Moderation(bot))

94
bot/cogs/reminders.py Normal file
View File

@@ -0,0 +1,94 @@
from datetime import datetime, timedelta
from discord.ext import 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
@commands.group()
@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")
async def reminder_add(
self, ctx: commands.Context, minutes: int, *, text: str
) -> None:
"""
Добавить новое напоминание.
:param ctx: Контекст команды.
:param minutes: Через сколько минут сработает напоминание.
:param text: Текст напоминания.
"""
if minutes <= 0:
await ctx.send("Время должно быть положительным числом минут.")
return
remind_time: datetime = 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]
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"
)
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("Неверный номер напоминания.")
return
removed: Reminder = storage.reminders.pop(number - 1)
storage.save_reminders()
await ctx.send(f"Удалено напоминание: {removed.text}")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Reminders(bot))

27
bot/help.py Normal file
View File

@@ -0,0 +1,27 @@
from discord.ext.commands import HelpCommand
from configs import settings
class MyHelpCommand(HelpCommand):
"""Кастомная команда help с текстовой справкой."""
async def send_bot_help(self, mapping, prefix: str = settings.PREFIX) -> None:
channel = self.get_destination()
help_text: str = (
"**Доступные команды:**\n"
f"`{prefix}help` — показать это сообщение\n"
f"`{prefix}rules` — показать правила сервера\n"
f"`{prefix}reminder add <минуты> <текст>` — добавить напоминание\n"
f"`{prefix}reminder list` — список напоминаний\n"
f"`{prefix}reminder remove <номер>` — удалить напоминание\n"
f"`{prefix}kick @пользователь [причина]` — исключить\n"
f"`{prefix}ban @пользователь [причина]` — забанить\n"
f"`{prefix}unban имя#дискриминатор` — разбанить\n"
f"`{prefix}mute @пользователь [причина]` — заглушить\n"
f"`{prefix}unmute @пользователь` — снять заглушение\n"
f"`{prefix}warn @пользователь [причина]` — предупреждение\n"
f"`{prefix}warnings @пользователь` — список предупреждений\n"
f"`{prefix}clear <кол-во>` — очистить чат\n"
f"`{prefix}blacklist_show/add/remove` — чёрный список\n"
)
await channel.send(help_text)

141
bot/storage.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List
from configs import settings
@dataclass
class Reminder:
"""
Модель напоминания.
Attributes:
time: Unix-время, когда нужно отправить напоминание.
channel_id: ID текстового канала.
user_mention: mention пользователя.
text: Текст напоминания.
"""
time: float
channel_id: int
user_mention: str
text: str
@dataclass
class Storage:
"""
Класс для работы с локальными JSONхранилищами:
предупреждения, напоминания, чёрный список.
"""
warnings_file: Path = settings.WARNINGS_FILE
reminders_file: Path = settings.REMINDERS_FILE
blacklist_file: Path = settings.BLACKLIST_FILE
reminders: List[Reminder] = field(default_factory=list)
user_warnings: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
blacklist: List[str] = field(default_factory=list)
def load_all(self) -> None:
"""
Загрузить все данные из файлов.
"""
self.load_warnings()
self.load_reminders()
self.load_blacklist()
def load_warnings(self) -> None:
"""
Загрузить предупреждения пользователей из JSONфайла.
"""
if self.warnings_file.is_file():
try:
data = json.loads(self.warnings_file.read_text(encoding="utf-8"))
if isinstance(data, dict):
self.user_warnings = data
else:
self.user_warnings = {}
except json.JSONDecodeError:
self.user_warnings = {}
def save_warnings(self) -> None:
"""
Сохранить предупреждения пользователей в JSONфайл.
"""
self.warnings_file.write_text(
json.dumps(self.user_warnings, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def load_reminders(self) -> None:
"""
Загрузить напоминания из JSONфайла.
"""
self.reminders.clear()
if self.reminders_file.is_file():
try:
data = json.loads(self.reminders_file.read_text(encoding="utf-8"))
if isinstance(data, list):
for item in data:
try:
self.reminders.append(
Reminder(
time=float(item["time"]),
channel_id=int(item["channel_id"]),
user_mention=str(item["user_mention"]),
text=str(item["text"]),
)
)
except (KeyError, ValueError, TypeError):
continue
except json.JSONDecodeError:
self.reminders = []
def save_reminders(self) -> None:
"""
Сохранить напоминания в JSONфайл.
"""
data: List[Dict[str, Any]] = [
{
"time": r.time,
"channel_id": r.channel_id,
"user_mention": r.user_mention,
"text": r.text,
}
for r in self.reminders
]
self.reminders_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def load_blacklist(self) -> None:
"""
Загрузить чёрный список слов из JSONфайла.
"""
self.blacklist.clear()
if self.blacklist_file.is_file():
try:
data = json.loads(self.blacklist_file.read_text(encoding="utf-8"))
if isinstance(data, list):
self.blacklist = [str(w).lower() for w in data]
else:
self.blacklist = []
except Exception:
self.blacklist = []
def save_blacklist(self) -> None:
"""
Сохранить чёрный список слов в JSONфайл.
"""
self.blacklist_file.write_text(
json.dumps(self.blacklist, ensure_ascii=False, indent=2),
encoding="utf-8",
)
storage: Storage = Storage()

1
configs/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .config import *

65
configs/config.py Normal file
View File

@@ -0,0 +1,65 @@
# config.py
from pathlib import Path
from typing import Optional
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class _Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
validate_default=True,
)
BOT_TOKEN: Optional[str] = None
PREFIX: Optional[str] = '!'
WELCOME_CHANNEL_ID: int = 0
ADMIN_ROLE_NAME: str = "Администратор"
WARNINGS_FILE: Path = Path("warnings.json")
REMINDERS_FILE: Path = Path("reminders.json")
BLACKLIST_FILE: Path = Path("blacklist.json")
LOG_FILE_NAME: Path = Path("bot.log")
LOG_LEVEL: str = "info"
@field_validator("BOT_TOKEN")
def validate_bot_token(cls, v: Optional[str]) -> str:
if not v:
raise ValueError("Не задан BOT_TOKEN (проверь .env или переменные окружения)")
return v
@field_validator("WELCOME_CHANNEL_ID")
def validate_channel_id(cls, v: int) -> int:
if v <= 0:
raise ValueError("WELCOME_CHANNEL_ID должен быть положительным ID канала")
return v
@field_validator("ADMIN_ROLE_NAME")
def validate_admin_role_name(cls, v: str) -> str:
if not v.strip():
raise ValueError("ADMIN_ROLE_NAME не может быть пустым")
return v
@field_validator("LOG_LEVEL")
def validate_log_level(cls, v: str) -> str:
allowed: set[str] = {"debug", "info", "warning", "error", "critical"}
if v.lower() not in allowed:
raise ValueError(f"LOG_LEVEL должен быть одним из: {', '.join(allowed)}")
return v.lower()
@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
# Создаём экземпляр класса настроек
settings = _Settings()
# Настройка экспорта в модули
__all__ = ("settings",)

18
main.py Normal file
View File

@@ -0,0 +1,18 @@
from asyncio import run
from bot import discbot
from configs import settings
from middleware import logger
async def main() -> None:
"""
Точка входа для асинхронного запуска бота.
"""
logger.setup()
await discbot.start(settings.BOT_TOKEN)
await discbot.start_bot()
if __name__ == "__main__":
run(main())

2
middleware/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .loggers import *
from .validators import *

View File

@@ -0,0 +1 @@
from .logs import *

194
middleware/loggers/logs.py Normal file
View File

@@ -0,0 +1,194 @@
from pathlib import Path
from functools import wraps
from sys import stderr as console
from inspect import iscoroutinefunction
from typing import Any, Callable, Optional, TypeVar, cast, Final
from loguru import logger as logs
from configs.config import settings # экземпляр настроек
__all__ = ("logger", "log")
F = TypeVar("F", bound=Callable[..., Any])
class _Logger:
"""
Обёртка над loguru с:
- единым форматом сообщений;
- выводом в консоль;
- файлами в ./logs:
- logs/bot.log — все уровни (DEBUG+)
- logs/debug.log — только DEBUG
- logs/info.log — только INFO
- logs/warning.log — только WARNING
- logs/error.log — только ERROR
- logs/critical.log — только CRITICAL
"""
_log_format: Final[str] = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <red>|</red> "
"<blue>{extra[system]}-{extra[log_type]}</blue> <red>|</red> "
"{extra[user]} <red>|</red> <level>{message}</level>"
)
def __init__(self, system_name: str = "DISCORD_BOT") -> None:
self.system_name: str = system_name
self._setup_done: bool = False
def setup(self, start: bool = True) -> None:
"""
Настроить loguru: консоль + файлы в каталоге logs/.
Вызывать один раз при старте приложения.
"""
if self._setup_done:
return
logs.remove()
# Директория для логов
log_dir: Path = Path("logs")
log_dir.mkdir(parents=True, exist_ok=True)
# Консольный вывод
logs.add(
sink=console,
format=self._log_format,
colorize=True,
level=settings.LOG_LEVEL.upper(),
)
# Общий лог (все уровни)
logs.add(
sink=log_dir / "bot.log",
rotation="100 MB",
retention="7 days",
format=self._log_format,
level="DEBUG",
enqueue=True,
backtrace=True,
diagnose=True,
)
# Отдельные файлы по уровням
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
logs.add(
sink=log_dir / f"{level_name.lower()}.log",
rotation="10 MB",
retention="7 days",
format=self._log_format,
level=level_name,
filter=lambda rec, lvl=level_name: rec["level"].name == lvl,
enqueue=True,
)
self._setup_done = True
if start:
self.log_entry(
level="INFO",
text="Запуск Discordбота...",
log_type="START",
)
@staticmethod
def format_user(user: Optional[str] = None) -> str:
"""
Вернуть строку пользователя для логов.
"""
return user or "@System"
def log_entry(
self,
level: str,
text: str,
log_type: str = "BOT",
user: Optional[str] = None,
) -> None:
"""
Записать строку лога.
:param level: Уровень (DEBUG, INFO, WARNING, ERROR, CRITICAL).
:param text: Текст сообщения.
:param log_type: Категория/подсистема (например, SYSTEM, COGS, DB).
:param user: Опционально — строка пользователя.
"""
actual_user: str = self.format_user(user)
logs.bind(
system=self.system_name,
user=actual_user,
log_type=log_type,
).log(level, text)
def log(
self,
level: str = "INFO",
log_type: str = "",
text: Optional[str] = None,
) -> Callable[[F], F]:
"""
Декоратор для логирования вызовов функций/корутин.
:param level: Уровень сообщения.
:param log_type: Категория (например, HANDLER, TASK).
:param text: Кастомный текст (по умолчанию 'Вызов <имя функции>').
"""
def decorator(func: F) -> F:
is_coroutine: bool = iscoroutinefunction(func)
action_text: str = text or f"Вызов {func.__name__}"
@wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
self.log_entry(level=level, text=f"[START] {action_text}", log_type=log_type)
try:
result: Any = func(*args, **kwargs)
self.log_entry(level=level, text=f"[SUCCESS] {action_text}", log_type=log_type)
return result
except Exception as e:
self.log_entry(
level="ERROR",
text=f"[ERROR] {action_text} | Exception: {e!r}",
log_type=log_type,
)
raise
@wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
self.log_entry(level=level, text=f"[START] {action_text}", log_type=log_type)
try:
result: Any = await func(*args, **kwargs)
self.log_entry(level=level, text=f"[SUCCESS] {action_text}", log_type=log_type)
return result
except Exception as e:
self.log_entry(
level="ERROR",
text=f"[ERROR] {action_text} | Exception: {e!r}",
log_type=log_type,
)
raise
return cast(F, async_wrapper if is_coroutine else sync_wrapper)
return decorator
def debug(self, text: str, log_type: str = "BOT", user: Optional[str] = None) -> None:
self.log_entry(level="DEBUG", text=text, log_type=log_type, user=user)
def info(self, text: str, log_type: str = "BOT", user: Optional[str] = None) -> None:
self.log_entry(level="INFO", text=text, log_type=log_type, user=user)
def warning(self, text: str, log_type: str = "BOT", user: Optional[str] = None) -> None:
self.log_entry(level="WARNING", text=text, log_type=log_type, user=user)
def error(self, text: str, log_type: str = "BOT", user: Optional[str] = None) -> None:
self.log_entry(level="ERROR", text=text, log_type=log_type, user=user)
def critical(self, text: str, log_type: str = "BOT", user: Optional[str] = None) -> None:
self.log_entry(level="CRITICAL", text=text, log_type=log_type, user=user)
# Глобальный экземпляр
logger: _Logger = _Logger(system_name="DISCORD_BOT")
log = logger.log

View File

@@ -0,0 +1,2 @@
from .email_vld import *
from .url_vld import *

View File

@@ -0,0 +1,24 @@
from typing import Optional
from email_validator import validate_email, EmailNotValidError, ValidatedEmail
# Настройка экспорта из этого модуля
__all__ = ("valid_email",)
def valid_email(e_mail: str) -> Optional[str]:
"""
Валидация почты через библиотеку.
:param e_mail: Получаемая почта.
:return: Нормализированная почта.
"""
try:
# Провека почты на валидность
email: ValidatedEmail = validate_email(e_mail)
except EmailNotValidError:
return None
# Возвращение строки с нормализированной почтой
return email.normalized

View File

@@ -0,0 +1,53 @@
from re import Pattern, compile
from ..loggers import logger
# Настройка экспорта
__all__ = ("valid_url", "url_to_text",)
def valid_url(url: str) -> bool:
"""
Проверяет, является ли строка валидной ссылкой (URL).
:param url: Строка для проверки.
:return: True, если строка является валидным URL, иначе False.
"""
try:
url_pattern: Pattern[str] = compile(
r'^(https?://)?' # Протокол (http или https, необязателен)
r'([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}' # Домен
r'(:\d+)?' # Порт (необязателен)
r'(/[-a-zA-Z0-9@:%_+.~#?&/=]*)?$' # Путь, параметры и фрагменты
)
return bool(url_pattern.match(url))
except ValueError as error:
# Перебрасываем ошибку выше для дальнейшей обработки или уведомления
logger.error(text=f'Ошибка валидации ссылки: {error}')
raise error
def url_to_text(text: str, url: str) -> str:
"""
Преобразует текст в HTML ссылку с указанным URL.
Эта функция генерирует HTML-ссылку с переданным текстом и URL, используя тег `<а>`, и делает ссылку жирной.
:param text: Текст, который будет отображаться для ссылки.
:param url: URL, который будет привязан к тексту.
:return: Строка с HTML кодом для ссылки, если URL валиден.
:raises ValueError: Если URL невалиден.
"""
try:
if not valid_url(url): # Проверяем, является ли URL валидным
raise ValueError(f"Переданный URL '{url}' невалиден.")
# Генерация HTML-ссылки
return f'<b><a href="{url}">{text}</a></b>'
except ValueError as error:
# Перебрасываем ошибку выше для дальнейшей обработки или уведомления
logger.error(text=f'Ошибка валидации ссылки в текст: {error}')
raise error

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "notfatekursach"
version = "0.1.0"
description = "None"
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)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"