Compare commits

...

14 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
16 changed files with 442 additions and 123 deletions

View File

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

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"]

View File

@@ -29,6 +29,8 @@ class Bot(commands.Bot):
intents.guilds = True intents.guilds = True
intents.message_content = True 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", "!")
@@ -73,6 +75,7 @@ class Bot(commands.Bot):
"bot.cogs.blacklist", "bot.cogs.blacklist",
"bot.cogs.reminders", "bot.cogs.reminders",
"bot.cogs.slash", "bot.cogs.slash",
"bot.cogs.music",
] ]
for cog in cogs: for cog in cogs:
try: try:

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

View File

@@ -20,6 +20,69 @@ class Events(Cog):
self.check_reminders.start() self.check_reminders.start()
@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:
""" """
Событие запуска бота. Событие запуска бота.

View File

@@ -55,25 +55,11 @@ class Moderation(Cog):
""" """
self.bot: Bot = bot self.bot: Bot = bot
@command()
@is_admin()
async def rules(self, ctx: 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)
@command()
@is_admin() @is_admin()
async def kick( async def kick(
self, self,
@@ -97,15 +83,12 @@ class Moderation(Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.") await ctx.send(f"Не удалось исключить {member} из-за ошибки Discord.")
@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: Context,
member: discord.Member,
*,
reason: Optional[str] = None,
) -> None:
""" """
Забанить участника на сервере. Забанить участника на сервере.
@@ -113,6 +96,9 @@ class Moderation(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,7 +107,10 @@ class Moderation(Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.") await ctx.send(f"Не удалось забанить {member} из-за ошибки Discord.")
@command() @discord.app_commands.command(
name="unban",
description="Разбанить пользователя по имени или тегу",
)
@has_permissions(ban_members=True) @has_permissions(ban_members=True)
async def unban(self, ctx: Context, *, member_name: str) -> None: async def unban(self, ctx: Context, *, member_name: str) -> None:
""" """
@@ -190,7 +179,10 @@ class Moderation(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))
@command() @discord.app_commands.command(
name="mute",
description="Выдать участнику мут",
)
@is_admin() @is_admin()
async def mute( async def mute(
self, self,
@@ -224,7 +216,10 @@ class Moderation(Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send("Не удалось выдать мут из-за ошибки Discord.") await ctx.send("Не удалось выдать мут из-за ошибки Discord.")
@command() @discord.app_commands.command(
name="unmute",
description="Снять мут с участника",
)
@is_admin() @is_admin()
async def unmute(self, ctx: Context, member: discord.Member) -> None: async def unmute(self, ctx: Context, member: discord.Member) -> None:
""" """
@@ -251,7 +246,10 @@ class Moderation(Cog):
except discord.HTTPException: except discord.HTTPException:
await ctx.send("Не удалось снять мут из-за ошибки Discord.") await ctx.send("Не удалось снять мут из-за ошибки Discord.")
@command() @discord.app_commands.command(
name="warn",
description="Выдать предупреждение участнику",
)
@is_admin() @is_admin()
async def warn( async def warn(
self, self,
@@ -267,6 +265,7 @@ class Moderation(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(
{ {
@@ -277,11 +276,18 @@ class Moderation(Cog):
} }
) )
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} предупреждения."
) )
@command() if warns_count >= max_warning:
await self.ban(ctx,member,reason=f"Превышен лимит предупреждений ({warns_count})")
@discord.app_commands.command(
name="warnings",
description="Показать предупреждения участника",
)
async def warnings(self, ctx: Context, member: discord.Member) -> None: async def warnings(self, ctx: Context, member: discord.Member) -> None:
""" """
Показать предупреждения участника. Показать предупреждения участника.
@@ -300,7 +306,10 @@ class Moderation(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))
@command() @discord.app_commands.command(
name="clear",
description="Очистить указанное количество сообщений в канале",
)
@is_admin() @is_admin()
async def clear(self, ctx: Context, amount: int) -> None: async def clear(self, ctx: Context, amount: int) -> None:
""" """

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,94 +1,74 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from discord.ext.commands import Cog, Bot, Context, group import discord
from discord.ext import commands
from discord import app_commands
from ..storage import storage, Reminder from ..storage import storage, Reminder
from .moderation import is_admin from .moderation import is_admin
class Reminders(Cog): class Reminders(commands.Cog):
""" """Cog для управления напоминаниями: add, list, remove через slash-команды."""
Cog для управления напоминаниями: add, list, remove.
"""
def __init__(self, bot: Bot) -> None:
self.bot: Bot = bot
@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: 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: 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: 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: 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: Bot) -> None: async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Reminders(bot)) await bot.add_cog(Reminders(bot))

View File

@@ -41,5 +41,7 @@ class Slash(commands.Cog):
) )
async def setup(bot: commands.Bot) -> None: async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Slash(bot)) 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

@@ -20,10 +20,11 @@ class _Settings(BaseSettings):
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

View File

@@ -15,3 +15,4 @@ async def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
run(main()) run(main())

View File

@@ -8,15 +8,16 @@ authors = [
license = {text = "MIT"} license = {text = "MIT"}
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"