2298 lines
91 KiB
Python
2298 lines
91 KiB
Python
## ================================================
|
||
# 📦 [БЛОК 1] Импорты и инициализация бота
|
||
# ================================================
|
||
|
||
import asyncio
|
||
import datetime
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import aiohttp
|
||
import xml.etree.ElementTree as ET
|
||
import html
|
||
import disnake
|
||
import feedparser
|
||
import pytz
|
||
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
|
||
from disnake import Message
|
||
from disnake.ext import commands, tasks
|
||
from scheduler import scheduled_event_loop
|
||
|
||
|
||
|
||
# 🎯 Интенты для отслеживания участников, сообщений, голосовых каналов и активности
|
||
intents = disnake.Intents.all()
|
||
intents.messages = True
|
||
intents.guilds = True
|
||
intents.members = True
|
||
intents.presences = True
|
||
intents.voice_states = True
|
||
intents.message_content = True # ⚠️ Это ВАЖНО оставить
|
||
|
||
|
||
# ⚙️ Инициализация бота (БЕЗ повторной строки)
|
||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||
|
||
# 📁 Имя файла для хранения событий
|
||
EVENTS_FILE = "event_tasks.json"
|
||
TICKETS_FILE = "tickets.json"
|
||
SCHEDULER_STATE_FILE = "scheduler_state.json"
|
||
|
||
def to_msk(ts: datetime.datetime) -> datetime.datetime:
|
||
"""Гарантированно возвращает aware-дату в Europe/Moscow."""
|
||
return MOSCOW_TZ.localize(ts) if ts.tzinfo is None else ts.astimezone(MOSCOW_TZ)
|
||
|
||
# 🔧 Универсальный шаблон Embed с автором и временем
|
||
def create_embed(
|
||
*,
|
||
title: str = None,
|
||
description: str = None,
|
||
color: disnake.Color = disnake.Color.blue(),
|
||
timestamp: datetime.datetime = None
|
||
) -> disnake.Embed:
|
||
embed = disnake.Embed(
|
||
title=title,
|
||
description=description,
|
||
color=color,
|
||
timestamp=timestamp or datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_author(
|
||
name=bot.user.name,
|
||
icon_url=bot.user.display_avatar.url
|
||
)
|
||
return embed
|
||
|
||
|
||
import logging
|
||
|
||
# 📁 Настройка логгера
|
||
logging.basicConfig(
|
||
level=logging.ERROR, # логгировать только ошибки и критичные сбои
|
||
filename="errors.log", # имя файла для записи ошибок
|
||
filemode="a", # 'a' — дописывать в файл; 'w' — каждый раз очищать
|
||
encoding="utf-8",
|
||
format="%(asctime)s | %(levelname)s | %(message)s",
|
||
datefmt="%d.%m.%Y %H:%M:%S"
|
||
)
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🔧 [БЛОК 2] Константы и структура данных
|
||
# ================================================
|
||
|
||
# 📌 Каналы
|
||
WELCOME_CHANNEL_ID = 1371097491662966895 # Куда отправляется embed при входе
|
||
ACTIVITY_REPORT_CHANNEL_ID = 1361230160547938355 # Куда отправляются отчёты об активности и повышениях
|
||
ERROR_LOG_CHANNEL_ID = 1361057662300852426 # Куда отправляются ошибки
|
||
GUILD_ID = 1351125004451844116 # ID сервера
|
||
SOURCE_CHANNEL_ID = 1366067509463220348 # Канал, где публикуются события [VLNKI]
|
||
POLL_CHANNEL_ID = 1351153746327109732 # Канал, куда публикуются голосования
|
||
REMINDER_CHANNEL_ID = 1351125004451844118 # Канал напоминаний за 2ч/30мин
|
||
|
||
|
||
# 🆔 Роли
|
||
NEWBIE_ROLE_ID = 1371100476493402163 # Новобранец
|
||
VLNKI_ROLE_ID = 1361253146738954280 # Роль [VLNKI] — меняет ник
|
||
GLAV_ROLE_ID = { 1355632357285040279,
|
||
1371332437631565876
|
||
} # Военкомат (доступ к !добавить_опыт)
|
||
CATEGORY_ID_TO_CHECK = 1362413791542907031 # Категория голосовых каналов
|
||
ROLE_SHTAB_ID = 1352007627806216342 # Роль Штаб — тегается при отчётах
|
||
ROLE_PLAYER_ID = 1352007040087756831 # Роль "Отрядный игрок" — доступ к !ивент
|
||
|
||
|
||
# 🧱 Исключённые роли (не получают опыт и не реагируют на антифлуд)
|
||
IGNORED_ROLE_IDS = {
|
||
1371331417333694494, 1371331543251025920, 1352007627806216342,
|
||
1361056253396123999, 1361752697937854737, 1351147823529201786,
|
||
1353323447375364139, 1355632357285040279, 1371332437631565876,
|
||
1371331747026829382, 1363645035639214294, 1353362577219911760,
|
||
1351197551860125717, 1351309207789506591, 1413803373370802206
|
||
}
|
||
|
||
KEEPER_ROLE_ID = 1355632357285040279
|
||
|
||
EXCLUDED_CHANNEL_IDS = {1426238350217711636,
|
||
} #Ожидание ивента
|
||
|
||
EXCLUDED_ROLE_IDS = {
|
||
1371331417333694494, 1371331543251025920, 1352007627806216342,
|
||
1361056253396123999, 1361752697937854737, 1351147823529201786,
|
||
1353323447375364139, 1355632357285040279, 1371332437631565876,
|
||
1371331747026829382, 1363645035639214294, 1353362577219911760,
|
||
1351197551860125717, 1351309207789506591, 1371100476493402163,
|
||
1361060646027792484
|
||
}
|
||
|
||
# 📈 Роли по уровням
|
||
LEVEL_ROLES = {
|
||
1: 1366086264897671248, # Младший сержант
|
||
2: 1367936114626793612, # Сержант
|
||
3: 1371208289026838579, # Старший сержант
|
||
4: 1371208783895986227, # Старшина
|
||
5: 1371209813874446386, # Прапорщик
|
||
6: 1371324489475821789, # Старший прапорщик
|
||
|
||
}
|
||
|
||
# 🎖️ Требуемый опыт (в минутах)
|
||
LEVEL_THRESHOLDS = {
|
||
1: 180,
|
||
2: 720,
|
||
3: 2880,
|
||
4: 4680,
|
||
5: 5880,
|
||
6: 8960
|
||
|
||
}
|
||
|
||
# 🎙️ Категория голосовых каналов, где разрешено начисление опыта
|
||
TRACKED_CATEGORY_IDS = [1362413791542907031]
|
||
|
||
# 🕒 Настройки опыта
|
||
XP_PER_MINUTE = 1
|
||
SAVE_INTERVAL_MINUTES = 5
|
||
|
||
# 💾 Данные пользователей: { user_id: {"xp": int, "minutes": int, "level": int} }
|
||
user_data = {}
|
||
user_activity_status = {} # Для отчёта о начале и завершении активности
|
||
last_message_times = {} # Для антифлуда
|
||
minutes_since_last_save = 0
|
||
|
||
# 📄 Путь к JSON-файлу
|
||
DATA_FILE = "user_data.json"
|
||
|
||
|
||
# ================================================
|
||
# 💾 [БЛОК 3] Работа с данными, уровни и формат
|
||
# ================================================
|
||
|
||
# Загрузка данных из JSON
|
||
def load_user_data():
|
||
global user_data
|
||
if os.path.exists(DATA_FILE):
|
||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||
try:
|
||
user_data.update(json.load(f))
|
||
except json.JSONDecodeError:
|
||
print("⚠️ Ошибка чтения user_data.json")
|
||
|
||
# Сохранение данных в JSON
|
||
def save_user_data():
|
||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
# 🕒 Состояние автосоздания ивентов (scheduler)
|
||
|
||
SCHEDULER_STATE_FILE = "scheduler_state.json"
|
||
|
||
def load_scheduler_state() -> bool:
|
||
"""
|
||
Загружает состояние автосоздания ивентов.
|
||
True — включено
|
||
False — выключено
|
||
"""
|
||
if not os.path.exists(SCHEDULER_STATE_FILE):
|
||
return False
|
||
|
||
try:
|
||
with open(SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return bool(data.get("enabled", False))
|
||
except Exception as e:
|
||
logging.exception("Ошибка чтения scheduler_state.json:")
|
||
print(f"⚠️ Ошибка чтения {SCHEDULER_STATE_FILE}: {e}")
|
||
return False
|
||
|
||
|
||
def save_scheduler_state(enabled: bool) -> None:
|
||
try:
|
||
with open(SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f:
|
||
json.dump({"enabled": bool(enabled)}, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
logging.exception("Ошибка записи scheduler_state.json:")
|
||
print(f"⚠️ Ошибка записи {SCHEDULER_STATE_FILE}: {e}")
|
||
|
||
|
||
# Получение уровня по XP
|
||
def get_level_from_xp(xp: int) -> int:
|
||
level = 1
|
||
for lvl, req_xp in sorted(LEVEL_THRESHOLDS.items()):
|
||
if xp >= req_xp:
|
||
level = lvl
|
||
else:
|
||
break
|
||
return level
|
||
|
||
# Получение имени звания по уровню
|
||
def get_role_name_by_level(level: int) -> str:
|
||
role_id = LEVEL_ROLES.get(level)
|
||
return f"<@&{role_id}>" if role_id else "Неизвестное звание"
|
||
|
||
# Форматирование времени в строку
|
||
def format_minutes(minutes: int) -> str:
|
||
hours = minutes // 60
|
||
mins = minutes % 60
|
||
return f"{hours}ч {mins}м"
|
||
|
||
# 🗄 Словарь: { message_id: { "applicant_id": int, "resolved": bool } }
|
||
tickets: dict[str, dict] = {}
|
||
|
||
|
||
# ========== Загрузка и сохранение ==========
|
||
def load_tickets():
|
||
global tickets
|
||
if os.path.exists(TICKETS_FILE):
|
||
with open(TICKETS_FILE, "r", encoding="utf-8") as f:
|
||
try:
|
||
tickets.update(json.load(f))
|
||
except json.JSONDecodeError:
|
||
print("⚠️ Ошибка чтения tickets.json")
|
||
|
||
def save_tickets():
|
||
with open(TICKETS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(tickets, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
# ================================================
|
||
# 🔁 [БЛОК 4] Активность, XP, embed-отчёты
|
||
# ================================================
|
||
|
||
|
||
# Создание embed-отчёта о начале или завершении активности
|
||
def create_activity_embed(member: disnake.Member, started: bool) -> disnake.Embed:
|
||
embed = disnake.Embed(
|
||
title="🎮 Активность Arma Reforger",
|
||
description=(
|
||
f"{member.mention} **начал** выполнять условия активности."
|
||
if started else
|
||
f"{member.mention} **завершил** выполнение условий активности."
|
||
),
|
||
color=disnake.Color.green() if started else disnake.Color.red(),
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_footer(text=f"ID: {member.id}", icon_url=member.display_avatar.url)
|
||
return embed
|
||
|
||
# Создание embed об уровне
|
||
def create_levelup_embed(member: disnake.Member, level: int) -> disnake.Embed:
|
||
role_name = get_role_name_by_level(level)
|
||
embed = disnake.Embed(
|
||
title="📈 Повышение звания!",
|
||
description=f"{member.mention} достиг **{level} уровня** и получил звание {role_name}!",
|
||
color=disnake.Color.gold(),
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_footer(text=f"ID: {member.id}", icon_url=member.display_avatar.url)
|
||
return embed
|
||
|
||
# ================================================
|
||
# 🔁 Цикл проверки активности и начисления XP
|
||
# ================================================
|
||
@tasks.loop(minutes=1)
|
||
async def activity_check_loop():
|
||
global minutes_since_last_save
|
||
|
||
for guild in bot.guilds:
|
||
for member in guild.members:
|
||
if member.bot:
|
||
continue
|
||
|
||
# Получаем ID всех ролей участника
|
||
member_role_ids = {role.id for role in member.roles}
|
||
|
||
# Проверяем: есть ли хотя бы одна ролевая из уровневых
|
||
has_level_role = any(rid in member_role_ids for rid in LEVEL_ROLES.values())
|
||
|
||
# Проверка на исключённые роли, если НЕТ уровневой роли
|
||
if not has_level_role and any(rid in EXCLUDED_ROLE_IDS for rid in member_role_ids):
|
||
continue
|
||
|
||
uid = str(member.id)
|
||
|
||
# Проверка условий активности
|
||
in_voice = (
|
||
member.voice
|
||
and member.voice.channel
|
||
and member.voice.channel.category_id in TRACKED_CATEGORY_IDS
|
||
and member.voice.channel.id not in EXCLUDED_CHANNEL_IDS
|
||
)
|
||
|
||
in_game = any(
|
||
a.name.lower() == "arma reforger"
|
||
for a in member.activities
|
||
if a.type == disnake.ActivityType.playing
|
||
)
|
||
active = in_voice and in_game
|
||
|
||
prev = user_activity_status.get(uid)
|
||
|
||
# 🟢 Пользователь ВОШЁЛ в активность — просто сохраняем время
|
||
if active and (not prev or not prev.get("active")):
|
||
user_activity_status[uid] = {
|
||
"active": True,
|
||
"start_time": datetime.datetime.now(MOSCOW_TZ)
|
||
}
|
||
|
||
# 🔴 Пользователь ВЫШЕЛ из активности — отправляем embed
|
||
elif not active and prev and prev.get("active"):
|
||
start_time = prev.get("start_time")
|
||
end_time = datetime.datetime.now(MOSCOW_TZ)
|
||
|
||
if start_time:
|
||
level = get_level_from_xp(user_data.get(uid, {}).get("xp", 0))
|
||
role_name = get_role_name_by_level(level)
|
||
duration = end_time - start_time
|
||
minutes_total = int(duration.total_seconds() // 60)
|
||
|
||
embed = disnake.Embed(
|
||
title="🎮 Активность завершена",
|
||
description=f"{member.mention} завершил выполнение условий активности.",
|
||
color=disnake.Color.red(),
|
||
timestamp=end_time
|
||
)
|
||
|
||
embed.add_field(name="Звание", value=role_name, inline=True)
|
||
embed.add_field(name="Длительность", value=f"{minutes_total} минут", inline=True)
|
||
embed.add_field(
|
||
name="⏱ Период активности",
|
||
value=f"**Начало:** <t:{int(start_time.timestamp())}:f>\n"
|
||
f"**Конец:** <t:{int(end_time.timestamp())}:f>",
|
||
inline=False
|
||
)
|
||
embed.set_footer(text=f"ID пользователя: {member.id}", icon_url=member.display_avatar.url)
|
||
|
||
channel = bot.get_channel(ACTIVITY_REPORT_CHANNEL_ID)
|
||
if channel:
|
||
await channel.send(embed=embed)
|
||
|
||
user_activity_status[uid] = {"active": False}
|
||
|
||
# ✅ Если пользователь активен — начисляем XP
|
||
if active:
|
||
user_data.setdefault(uid, {
|
||
"xp": 0,
|
||
"minutes": 0,
|
||
"level": 1,
|
||
"last_active": datetime.datetime.now(MOSCOW_TZ).isoformat(),
|
||
"name": member.display_name
|
||
})
|
||
|
||
# 🔁 При каждом цикле обновляем имя (если поменялось)
|
||
user_data[uid]["name"] = member.display_name
|
||
|
||
user_data[uid]["xp"] += XP_PER_MINUTE
|
||
user_data[uid]["minutes"] += 1
|
||
|
||
current_level = get_level_from_xp(user_data[uid]["xp"])
|
||
previous_level = user_data[uid].get("level", 1)
|
||
|
||
if current_level > previous_level:
|
||
user_data[uid]["level"] = current_level
|
||
|
||
# Снимаем старые уровневые роли
|
||
for lvl, rid in LEVEL_ROLES.items():
|
||
role = member.guild.get_role(rid)
|
||
if role and role in member.roles:
|
||
await member.remove_roles(role)
|
||
|
||
# Выдаём новую роль
|
||
new_role_id = LEVEL_ROLES.get(current_level)
|
||
if new_role_id:
|
||
role = member.guild.get_role(new_role_id)
|
||
if role:
|
||
await member.add_roles(role)
|
||
|
||
# Отправляем embed о повышении
|
||
embed = disnake.Embed(
|
||
title="📈 Повышение звания!",
|
||
description=f"{member.mention} достиг **{current_level} уровня** и получил звание {get_role_name_by_level(current_level)}!",
|
||
color=disnake.Color.gold(),
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_footer(text=f"ID пользователя: {member.id}", icon_url=member.display_avatar.url)
|
||
channel = bot.get_channel(ACTIVITY_REPORT_CHANNEL_ID)
|
||
if channel:
|
||
await channel.send(embed=embed)
|
||
|
||
# 💾 Сохраняем данные каждые N минут
|
||
minutes_since_last_save += 1
|
||
if minutes_since_last_save >= SAVE_INTERVAL_MINUTES:
|
||
save_user_data()
|
||
minutes_since_last_save = 0
|
||
|
||
|
||
|
||
# ================================================
|
||
# 👤 [БЛОК 5] Вход на сервер, VLNKI, антифлуд
|
||
# ================================================
|
||
|
||
# 📥 Приветствие и выдача роли новичка
|
||
@bot.event
|
||
async def on_member_join(member: disnake.Member):
|
||
role = member.guild.get_role(NEWBIE_ROLE_ID)
|
||
channel = member.guild.get_channel(WELCOME_CHANNEL_ID)
|
||
|
||
if role:
|
||
await member.add_roles(role, reason="Вступление на сервер")
|
||
|
||
if channel:
|
||
embed = disnake.Embed(
|
||
title="👋 Прибыл новый боец!",
|
||
description=f"{member.mention}, выдано звание Рядовой!",
|
||
color=disnake.Color.green()
|
||
)
|
||
embed.set_footer(text=f"ID: {member.id}")
|
||
embed.set_thumbnail(url=member.display_avatar.url)
|
||
await channel.send(embed=embed)
|
||
|
||
# 🎖 Автоматическое изменение ника при выдаче роли VLNKI
|
||
@bot.event
|
||
async def on_member_update(before: disnake.Member, after: disnake.Member):
|
||
# ID роли VLNKI
|
||
VLNKI_ROLE_ID = 1361253146738954280
|
||
|
||
# Было ли изменение ролей
|
||
before_roles = set(r.id for r in before.roles)
|
||
after_roles = set(r.id for r in after.roles)
|
||
|
||
# Если роль VLNKI была добавлена
|
||
if VLNKI_ROLE_ID not in before_roles and VLNKI_ROLE_ID in after_roles:
|
||
try:
|
||
# Новый ник с префиксом [VLNKI]
|
||
new_nick = after.display_name
|
||
if not new_nick.startswith("[VLNKI]"):
|
||
new_nick = f"[VLNKI] {new_nick}"
|
||
|
||
# Меняем ник
|
||
await after.edit(nick=new_nick, reason="Выдана роль VLNKI")
|
||
|
||
# Лог в консоль
|
||
print(f"✅ Ник изменён для {after.name}: {new_nick}")
|
||
except disnake.Forbidden:
|
||
print(f"⚠️ Не хватает прав, чтобы изменить ник {after.name}")
|
||
except Exception as e:
|
||
logging.exception("Ошибка при изменении ника:")
|
||
print(f"❌ Ошибка при изменении ника: {e}")
|
||
|
||
# Проверка сообщений на бота
|
||
@bot.event
|
||
async def on_message(message: disnake.Message):
|
||
# 🔒 Игнорируем только самого себя
|
||
if message.author.id == bot.user.id:
|
||
return
|
||
|
||
# 🛡️ Антифлуд — только для обычных людей (не вебхуки, не системные сообщения)
|
||
if message.webhook_id is None and not any(role.id in IGNORED_ROLE_IDS for role in message.author.roles):
|
||
now = message.created_at.timestamp()
|
||
last_time = last_message_times.get(message.author.id, 0)
|
||
if now - last_time < 2:
|
||
try:
|
||
await message.delete()
|
||
warn = await message.channel.send(f"{message.author.mention} Не так часто, боец!")
|
||
await asyncio.sleep(5)
|
||
await warn.delete()
|
||
except disnake.Forbidden:
|
||
pass
|
||
last_message_times[message.author.id] = now
|
||
|
||
# 📥 Парсинг события
|
||
info = parse_event_info(message.content)
|
||
print(f"🧩 Результат парсинга: {info}")
|
||
|
||
if info:
|
||
is_duplicate = any(
|
||
info["name"].lower() == data["event"]["name"].lower()
|
||
and info["start_time"] == data["event"]["start_time"]
|
||
for data in event_tasks.values()
|
||
)
|
||
|
||
if not is_duplicate:
|
||
await create_poll(info, message.id)
|
||
else:
|
||
print("⚠️ Событие уже существует: имя и дата совпадают.")
|
||
|
||
# ✅ Обработка команд
|
||
await bot.process_commands(message)
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🏅 [БЛОК 6] Команды: !ранг, !топ
|
||
# ================================================
|
||
|
||
@bot.command(name="ранг", aliases=["звание"])
|
||
async def rank(ctx, *, arg: str = None):
|
||
# Приоритет ролей, освобождающих от службы
|
||
exemption_priority = [
|
||
(1371332437631565876, "Генерал"),
|
||
(1371331747026829382, "Майор"),
|
||
(1371331543251025920, "Капитан"),
|
||
(1371331417333694494, "Старший лейтенант"),
|
||
(1355632357285040279, "Военкомат"),
|
||
(1352007627806216342, "Штаб"),
|
||
(1371100476493402163, "Рядовой"),
|
||
(1361060646027792484, "Дух")
|
||
]
|
||
|
||
# Получение участника
|
||
member = None
|
||
|
||
if not arg:
|
||
member = ctx.author
|
||
else:
|
||
# Проверка: упоминание
|
||
if ctx.message.mentions:
|
||
member = ctx.message.mentions[0]
|
||
else:
|
||
# Поиск по нику или username
|
||
arg_lower = arg.lower()
|
||
member = disnake.utils.find(
|
||
lambda m: arg_lower in m.display_name.lower() or arg_lower in m.name.lower(),
|
||
ctx.guild.members
|
||
)
|
||
|
||
if not member:
|
||
await ctx.send(f"❌ Пользователь с именем `{arg}` не найден.")
|
||
return
|
||
|
||
uid = str(member.id)
|
||
|
||
# Проверка на освобождение
|
||
exemption = None
|
||
for role_id, title in exemption_priority:
|
||
if disnake.utils.get(member.roles, id=role_id):
|
||
exemption = title
|
||
break
|
||
|
||
data = user_data.get(uid)
|
||
|
||
if exemption:
|
||
if not data:
|
||
embed = disnake.Embed(
|
||
title="⚠️ Освобождение от службы",
|
||
description=f"{member.display_name} освобождён от службы по званию: **{exemption}**.",
|
||
color=disnake.Color.orange()
|
||
)
|
||
embed.set_thumbnail(url=member.display_avatar.url)
|
||
await ctx.send(embed=embed)
|
||
return
|
||
else:
|
||
# Пользователь освобождён, но у него есть XP
|
||
xp = data.get("xp", 0)
|
||
if xp > 0:
|
||
embed = disnake.Embed(
|
||
title="⚠️ Нарушение: освобождён, но с опытом",
|
||
description=(
|
||
f"{member.display_name} имеет звание **{exemption}**, освобождён от службы, "
|
||
f"но уже получил **{xp} опыта**."
|
||
),
|
||
color=disnake.Color.red()
|
||
)
|
||
embed.set_thumbnail(url=member.display_avatar.url)
|
||
await ctx.send(embed=embed)
|
||
return
|
||
|
||
if not data:
|
||
await ctx.send(f"{member.display_name} ещё не начал службу.")
|
||
return
|
||
|
||
level = get_level_from_xp(data.get("xp", 0))
|
||
role_name = get_role_name_by_level(level)
|
||
service_time = format_minutes(data.get("minutes", 0))
|
||
|
||
embed = disnake.Embed(
|
||
title=f"🎖 Звание бойца {member.display_name}",
|
||
color=disnake.Color.blue()
|
||
)
|
||
embed.add_field(name="Звание", value=role_name, inline=False)
|
||
embed.add_field(name="Уровень", value=str(level), inline=True)
|
||
embed.add_field(name="Срок службы", value=service_time, inline=True)
|
||
|
||
if exemption:
|
||
embed.add_field(
|
||
name="⚠️ Освобождение",
|
||
value=f"Пользователь освобождён от службы по званию: **{exemption}**.",
|
||
inline=False
|
||
)
|
||
|
||
embed.set_thumbnail(url=member.display_avatar.url)
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
|
||
@bot.command(name="топ")
|
||
async def top(ctx):
|
||
if not user_data:
|
||
await ctx.send("📭 Нет данных о пользователях.")
|
||
return
|
||
|
||
sorted_users = sorted(user_data.items(), key=lambda item: (item[1].get("xp", 0)), reverse=True)[:10]
|
||
embed = disnake.Embed(title="🏆 Топ бойцов", color=disnake.Color.gold())
|
||
|
||
for i, (uid, data) in enumerate(sorted_users, start=1):
|
||
try:
|
||
member = await ctx.guild.fetch_member(int(uid))
|
||
|
||
if not member:
|
||
member = await bot.fetch_user(int(uid))
|
||
name = member.display_name if hasattr(member, "display_name") else member.name
|
||
except Exception:
|
||
name = f"Пользователь {uid}"
|
||
|
||
level = get_level_from_xp(data.get("xp", 0))
|
||
role = get_role_name_by_level(level)
|
||
minutes = format_minutes(data.get("minutes", 0))
|
||
|
||
embed.add_field(
|
||
name=f"#{i} {name}",
|
||
value=f"{role} | {minutes}",
|
||
inline=False
|
||
)
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
|
||
# ================================================
|
||
# ⚙️ [БЛОК 7] Админ-команды: опыт и очистка сообщений
|
||
# ================================================
|
||
|
||
# ✅ Проверка: есть ли нужная роль у пользователя
|
||
def has_admin_role(member: disnake.Member) -> bool:
|
||
return any(role.id in GLAV_ROLE_ID for role in member.roles)
|
||
|
||
# 📈 Добавить опыт
|
||
@bot.command(name="добавить_опыт")
|
||
async def add_experience(ctx, *, args: str):
|
||
if not has_admin_role(ctx.author):
|
||
await ctx.send("❌ У вас нет прав на выполнение этой команды.")
|
||
return
|
||
|
||
parts = args.strip().split()
|
||
if len(parts) < 2:
|
||
await ctx.send("❗ Укажите пользователя и количество опыта. Пример: `!добавить_опыт Kolumb 100`")
|
||
return
|
||
|
||
name_or_mention = " ".join(parts[:-1])
|
||
try:
|
||
amount = int(parts[-1])
|
||
except ValueError:
|
||
await ctx.send("❗ Укажите корректное число опыта.")
|
||
return
|
||
|
||
try:
|
||
member = await commands.MemberConverter().convert(ctx, name_or_mention)
|
||
except commands.BadArgument:
|
||
name_lower = name_or_mention.lower()
|
||
member = next(
|
||
(m for m in ctx.guild.members if name_lower in m.display_name.lower() or name_lower in m.name.lower()),
|
||
None
|
||
)
|
||
|
||
if not member:
|
||
await ctx.send("❌ Пользователь не найден.")
|
||
return
|
||
|
||
uid = str(member.id)
|
||
|
||
# ✅ Проверка на нарушение условий
|
||
member_role_ids = {r.id for r in member.roles}
|
||
has_level_role = any(rid in member_role_ids for rid in LEVEL_ROLES.values())
|
||
has_ignored_role = any(rid in member_role_ids for rid in IGNORED_ROLE_IDS)
|
||
|
||
if has_ignored_role and not has_level_role:
|
||
# Отправляем предупреждение
|
||
embed = disnake.Embed(
|
||
title="🚫 Нарушение условий",
|
||
description=(
|
||
f"Пользователь {member.mention} имеет **исключённую роль**, "
|
||
f"но **не имеет ни одной уровневой роли**.\n\n"
|
||
f"❌ Начисление опыта запрещено."
|
||
),
|
||
color=disnake.Color.red(),
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_footer(text=f"ID: {member.id}", icon_url=member.display_avatar.url)
|
||
await ctx.send(embed=embed)
|
||
return
|
||
|
||
# ✅ Начисление опыта
|
||
user_data.setdefault(uid, {"xp": 0, "minutes": 0, "level": 1})
|
||
user_data[uid]["xp"] += amount
|
||
user_data[uid]["minutes"] += amount
|
||
save_user_data()
|
||
|
||
await ctx.send(f"✅ Добавлено {amount} минут/опыта пользователю **{member.display_name}**.")
|
||
|
||
|
||
# 📉 Отнять опыт
|
||
@bot.command(name="отнять_опыт")
|
||
async def remove_experience(ctx, *, args: str):
|
||
if not has_admin_role(ctx.author):
|
||
await ctx.send("❌ У вас нет прав на выполнение этой команды.")
|
||
return
|
||
|
||
try:
|
||
parts = args.rsplit(" ", 1)
|
||
target_str = parts[0].strip()
|
||
amount = int(parts[1])
|
||
except (IndexError, ValueError):
|
||
await ctx.send("⚠️ Используйте: `!отнять_опыт @пользователь <число>`")
|
||
return
|
||
|
||
member = None
|
||
|
||
# 1. Если есть упомянутый пользователь
|
||
if ctx.message.mentions:
|
||
member = ctx.message.mentions[0]
|
||
else:
|
||
clean_target = target_str.lower().lstrip("@")
|
||
|
||
for m in ctx.guild.members:
|
||
name = m.name.lower()
|
||
nick = m.nick.lower() if m.nick else ""
|
||
if clean_target in name or clean_target in nick:
|
||
member = m
|
||
break
|
||
|
||
if not member:
|
||
await ctx.send(f"❌ Пользователь `{target_str}` не найден.")
|
||
return
|
||
|
||
uid = str(member.id)
|
||
if uid not in user_data:
|
||
await ctx.send("❗ У пользователя нет данных.")
|
||
return
|
||
|
||
# Отнимаем опыт и обновляем уровень
|
||
user_data[uid]["xp"] = max(0, user_data[uid]["xp"] - amount)
|
||
user_data[uid]["minutes"] = max(0, user_data[uid]["minutes"] - amount)
|
||
user_data[uid]["level"] = get_level_from_xp(user_data[uid]["xp"])
|
||
|
||
# Переназначаем роль
|
||
for lvl, rid in LEVEL_ROLES.items():
|
||
role = member.guild.get_role(rid)
|
||
if role and role in member.roles:
|
||
await member.remove_roles(role)
|
||
|
||
new_role_id = LEVEL_ROLES.get(user_data[uid]["level"])
|
||
if new_role_id:
|
||
new_role = member.guild.get_role(new_role_id)
|
||
if new_role:
|
||
await member.add_roles(new_role)
|
||
|
||
save_user_data()
|
||
await ctx.send(f"🔄 Обновлены данные пользователя **{member.display_name}**. Опыт уменьшен на {amount}.")
|
||
|
||
|
||
# Удаление опыта
|
||
@bot.command(name="удалить_опыт")
|
||
async def reset_user_experience(ctx, *, args: str):
|
||
if not has_admin_role(ctx.author):
|
||
await ctx.send("❌ У вас нет прав на выполнение этой команды.")
|
||
return
|
||
|
||
if not args.strip():
|
||
await ctx.send("❗ Укажите пользователя для удаления данных. Пример: `!удаление_опыта Kolumb`")
|
||
return
|
||
|
||
name_or_mention = args.strip()
|
||
|
||
# Попытка получить участника по упоминанию или ID
|
||
try:
|
||
member = await commands.MemberConverter().convert(ctx, name_or_mention)
|
||
except commands.BadArgument:
|
||
# Поиск по имени
|
||
name_lower = name_or_mention.lower()
|
||
member = next(
|
||
(m for m in ctx.guild.members if name_lower in m.display_name.lower() or name_lower in m.name.lower()),
|
||
None
|
||
)
|
||
|
||
if not member:
|
||
await ctx.send("❌ Пользователь не найден.")
|
||
return
|
||
|
||
uid = str(member.id)
|
||
|
||
if uid not in user_data:
|
||
await ctx.send(f"ℹ️ У пользователя {member.display_name} нет данных об опыте.")
|
||
return
|
||
|
||
# Удаляем все уровневые роли
|
||
for lvl, role_id in LEVEL_ROLES.items():
|
||
role = ctx.guild.get_role(role_id)
|
||
if role and role in member.roles:
|
||
try:
|
||
await member.remove_roles(role)
|
||
except disnake.Forbidden:
|
||
await ctx.send(f"⚠️ Не удалось снять роль {role.name} с пользователя {member.display_name}.")
|
||
|
||
# Удаляем данные
|
||
del user_data[uid]
|
||
save_user_data()
|
||
|
||
# Отправляем подтверждение
|
||
embed = disnake.Embed(
|
||
title="🗑️ Опыт удалён",
|
||
description=(
|
||
f"Данные об опыте и званиях пользователя {member.mention} были полностью удалены.\n"
|
||
f"Роли, полученные при повышении, сняты."
|
||
),
|
||
color=disnake.Color.red(),
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
embed.set_footer(text=f"ID: {member.id}", icon_url=member.display_avatar.url)
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
# Сброс опыта у всех
|
||
@bot.command(name="сброс_всё")
|
||
async def reset_all_data(ctx):
|
||
# Проверка: является ли автор военкоматом
|
||
if not has_admin_role(ctx.author):
|
||
await ctx.send("❌ У вас нет прав использовать эту команду.")
|
||
return
|
||
|
||
count = len(user_data)
|
||
|
||
# Очистка всех данных
|
||
user_data.clear()
|
||
save_user_data()
|
||
|
||
# Ответ в embed
|
||
embed = disnake.Embed(
|
||
title="🗑️ Сброс всех данных",
|
||
description=f"Удалён прогресс у {count} пользователей.\nОпыт, уровень и срок службы обнулены.",
|
||
color=disnake.Color.red()
|
||
)
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
# 🧹 Удалить несколько сообщений
|
||
@bot.command(name="удалить")
|
||
@commands.has_permissions(manage_messages=True)
|
||
async def clear_messages(ctx, amount: int):
|
||
if amount < 1:
|
||
await ctx.send("❗ Укажите положительное число.")
|
||
return
|
||
deleted = await ctx.channel.purge(limit=amount + 1)
|
||
await ctx.send(f"🧹 Удалено {len(deleted) - 1} сообщений.", delete_after=5)
|
||
|
||
# 💣 Удалить все сообщения
|
||
@bot.command(name="удалить_все")
|
||
@commands.has_permissions(administrator=True)
|
||
async def delete_all(ctx):
|
||
deleted = await ctx.channel.purge(limit=None)
|
||
await ctx.send(f"💣 Удалено {len(deleted)} сообщений.", delete_after=5)
|
||
|
||
|
||
from disnake.ext import commands
|
||
|
||
|
||
# Команда удаления опроса по ID сообщения
|
||
@bot.command(name='удалить_опрос')
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def delete_poll(ctx, message_id: int):
|
||
guild = bot.get_guild(GUILD_ID)
|
||
if guild is None:
|
||
await ctx.send("Ошибка: сервер (guild) не найден.")
|
||
return
|
||
|
||
channel = guild.get_channel(POLL_CHANNEL_ID)
|
||
if channel is None:
|
||
await ctx.send("Ошибка: канал опросов не найден.")
|
||
return
|
||
|
||
try:
|
||
msg = await channel.fetch_message(message_id)
|
||
except Exception as e:
|
||
logging.exception("Ошибка: сообщение с ID:")
|
||
await ctx.send(f"Ошибка: сообщение с ID {message_id} не найдено.\n{e}")
|
||
return
|
||
|
||
# Лог в консоль, чтобы проверить канал и ID сообщения
|
||
print(f"Удаление сообщения ID {message_id} в канале {channel.name} ({channel.id})")
|
||
|
||
try:
|
||
await msg.delete()
|
||
await ctx.send(f"Опрос с сообщением ID {message_id} успешно удалён.")
|
||
except Exception as e:
|
||
logging.exception("Ошибка при удалении сообщения:")
|
||
await ctx.send(f"Ошибка при удалении сообщения: {e}")
|
||
|
||
|
||
@bot.command(name="ивент_восстановить")
|
||
@commands.has_role(ROLE_SHTAB_ID) # доступ только для штаба
|
||
async def restore_event(ctx, *, arg: str):
|
||
try:
|
||
# Пример: !восстановить_ивент Red Bear Community | 11.06.2025 20:00
|
||
name_part, time_part = map(str.strip, arg.split("|"))
|
||
dt = datetime.datetime.strptime(time_part, "%d.%m.%Y %H:%M")
|
||
info = {
|
||
"name": name_part,
|
||
"start_time": dt
|
||
}
|
||
|
||
fake_id = f"manual_{int(dt.timestamp())}"
|
||
if fake_id in event_tasks:
|
||
await ctx.send("❌ Ивент уже существует.")
|
||
return
|
||
|
||
await create_poll(info, message_id=fake_id, is_restore=True)
|
||
await ctx.send("✅ Ивент успешно восстановлен.")
|
||
except Exception as e:
|
||
logging.exception("Ошибка восстановления:")
|
||
await ctx.send(f"⚠️ Ошибка восстановления: {e}")
|
||
|
||
|
||
@bot.command(name="создать_голосования")
|
||
async def create_scheduled_events(ctx):
|
||
"""Принудительно запускает создание голосований по расписанию"""
|
||
import scheduler # импортируем модуль с расписанием
|
||
|
||
await ctx.send("⏳ Проверяю расписание и создаю голосования...")
|
||
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
created = []
|
||
|
||
generate_event_id = scheduler.event_loop_context["generate_event_id"]
|
||
event_tasks = scheduler.event_loop_context["event_tasks"]
|
||
create_poll = scheduler.event_loop_context["create_poll"]
|
||
|
||
for event in scheduler.SCHEDULED_EVENTS:
|
||
# День и время создания (по event["day"])
|
||
create_time = scheduler.get_next_datetime(
|
||
now,
|
||
event["day"],
|
||
event["create_hour"],
|
||
event["create_minute"]
|
||
)
|
||
|
||
# День и время старта (учитываем event_day!)
|
||
start_day = event.get("event_day", event["day"])
|
||
start_time = scheduler.get_next_datetime(
|
||
now,
|
||
start_day,
|
||
event["event_hour"],
|
||
event["event_minute"]
|
||
)
|
||
|
||
event_id = generate_event_id(event["name"], start_time)
|
||
|
||
if event_id in event_tasks:
|
||
continue
|
||
|
||
info = {
|
||
"name": event["name"],
|
||
"start_time": start_time,
|
||
}
|
||
|
||
await create_poll(info, message_id=event["key"])
|
||
created.append(f"{info['name']} ({start_time.strftime('%d.%m.%Y %H:%M')} МСК)")
|
||
|
||
if created:
|
||
await ctx.send("✅ Созданы события:\n" + "\n".join(created))
|
||
else:
|
||
await ctx.send("ℹ️ Все события уже существуют, ничего не создано.")
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🕒 [SCHEDULER] Команды управления автосозданием
|
||
# ================================================
|
||
|
||
@bot.command(name="автоивенты_вкл", aliases=["auto_on", "scheduler_on"])
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def scheduler_enable(ctx):
|
||
import scheduler
|
||
scheduler.AUTO_EVENTS_ENABLED = True
|
||
from scheduler import SCHEDULED_EVENTS, get_next_datetime
|
||
|
||
# 💾 сохраняем состояние
|
||
save_scheduler_state(True)
|
||
scheduler.AUTO_EVENTS_ENABLED = True
|
||
|
||
# ▶️ запускаем scheduler
|
||
if not scheduler.scheduled_event_loop.is_running():
|
||
scheduler.scheduled_event_loop.start()
|
||
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
enabled = load_scheduler_state()
|
||
running = scheduler.scheduled_event_loop.is_running()
|
||
|
||
# 🔎 поиск ближайшего автосоздания
|
||
nearest = None
|
||
nearest_time = None
|
||
|
||
for event in SCHEDULED_EVENTS:
|
||
ct = get_next_datetime(now, event["day"], event["create_hour"], event["create_minute"])
|
||
if ct >= now and (nearest_time is None or ct < nearest_time):
|
||
nearest_time = ct
|
||
nearest = event
|
||
|
||
embed = disnake.Embed(
|
||
title="⚙️ Автоивенты — включено",
|
||
color=disnake.Color.green(),
|
||
timestamp=now
|
||
)
|
||
|
||
embed.add_field(
|
||
name="⚙️ Автосоздание",
|
||
value="🟢 **ВКЛЮЧЕНО**",
|
||
inline=False
|
||
)
|
||
|
||
embed.add_field(
|
||
name="🔄 Scheduler",
|
||
value="🟢 Запущен" if running else "🔴 Остановлен",
|
||
inline=False
|
||
)
|
||
|
||
if nearest and nearest_time:
|
||
create_ts = int(nearest_time.timestamp())
|
||
|
||
event_day = nearest.get("event_day", nearest["day"])
|
||
start_time = get_next_datetime(
|
||
now,
|
||
event_day,
|
||
nearest["event_hour"],
|
||
nearest["event_minute"]
|
||
)
|
||
start_ts = int(start_time.timestamp())
|
||
|
||
embed.add_field(
|
||
name="⏭ Ближайшее автосоздание",
|
||
value=(
|
||
f"**{nearest['name']}**\n"
|
||
f"🛠 Создание голосования:\n"
|
||
f"• По МСК: `{nearest_time.strftime('%d.%m.%Y %H:%M')}`\n"
|
||
f"• Локально: <t:{create_ts}:F>\n\n"
|
||
f"🎯 **Начало события:**\n"
|
||
f"• По МСК: `{start_time.strftime('%d.%m.%Y %H:%M')}`\n"
|
||
f"• Локально: <t:{start_ts}:F>\n\n"
|
||
f"🔑 key: `{nearest.get('key', '—')}`"
|
||
),
|
||
inline=False
|
||
)
|
||
|
||
embed.set_footer(text="Команда: !автоивенты_вкл")
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
@bot.command(name="автоивенты_выкл", aliases=["auto_off", "scheduler_off"])
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def scheduler_disable(ctx):
|
||
import scheduler
|
||
scheduler.AUTO_EVENTS_ENABLED = False
|
||
|
||
# 💾 сохраняем состояние
|
||
save_scheduler_state(False)
|
||
scheduler.AUTO_EVENTS_ENABLED = False
|
||
|
||
# ⛔ останавливаем scheduler
|
||
if scheduler.scheduled_event_loop.is_running():
|
||
scheduler.scheduled_event_loop.stop()
|
||
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
|
||
embed = disnake.Embed(
|
||
title="⚙️ Автоивенты — выключено",
|
||
color=disnake.Color.red(),
|
||
timestamp=now
|
||
)
|
||
|
||
embed.add_field(
|
||
name="⚙️ Автосоздание",
|
||
value="⛔ **ВЫКЛЮЧЕНО**",
|
||
inline=False
|
||
)
|
||
|
||
embed.add_field(
|
||
name="🔄 Scheduler",
|
||
value="🔴 Остановлен",
|
||
inline=False
|
||
)
|
||
|
||
embed.set_footer(text="Команда: !автоивенты_выкл")
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
@bot.command(name="автоивенты_статус", aliases=["auto_status", "scheduler_status"])
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def scheduler_status(ctx):
|
||
import scheduler
|
||
from scheduler import SCHEDULED_EVENTS, get_next_datetime
|
||
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
|
||
enabled = load_scheduler_state()
|
||
running = scheduler.scheduled_event_loop.is_running()
|
||
|
||
status_text = "🟢 **ВКЛЮЧЕНО**" if enabled else "⛔ **ВЫКЛЮЧЕНО**"
|
||
loop_text = "🟢 Запущен" if running else "🔴 Остановлен"
|
||
|
||
# 🔎 ближайшее автосоздание
|
||
nearest = None
|
||
nearest_time = None
|
||
|
||
for event in SCHEDULED_EVENTS:
|
||
ct = get_next_datetime(now, event["day"], event["create_hour"], event["create_minute"])
|
||
if ct >= now and (nearest_time is None or ct < nearest_time):
|
||
nearest_time = ct
|
||
nearest = event
|
||
|
||
embed = disnake.Embed(
|
||
title="📊 Статус автоматических ивентов",
|
||
color=disnake.Color.green() if enabled else disnake.Color.red(),
|
||
timestamp=now
|
||
)
|
||
|
||
embed.add_field(name="⚙️ Автосоздание", value=status_text, inline=False)
|
||
embed.add_field(name="🔄 Scheduler", value=loop_text, inline=False)
|
||
|
||
# ⚠️ рассинхрон
|
||
if enabled and not running:
|
||
embed.add_field(
|
||
name="⚠️ Внимание",
|
||
value="Автосоздание **включено**, но scheduler сейчас **не запущен**.",
|
||
inline=False
|
||
)
|
||
if (not enabled) and running:
|
||
embed.add_field(
|
||
name="⚠️ Внимание",
|
||
value="Автосоздание **выключено**, но scheduler сейчас **запущен**.",
|
||
inline=False
|
||
)
|
||
|
||
if nearest and nearest_time:
|
||
create_ts = int(nearest_time.timestamp())
|
||
|
||
event_day = nearest.get("event_day", nearest["day"])
|
||
start_time = get_next_datetime(
|
||
now,
|
||
event_day,
|
||
nearest["event_hour"],
|
||
nearest["event_minute"]
|
||
)
|
||
start_ts = int(start_time.timestamp())
|
||
|
||
embed.add_field(
|
||
name="⏭ Ближайшее автосоздание",
|
||
value=(
|
||
f"**{nearest['name']}**\n"
|
||
f"🛠 Создание голосования:\n"
|
||
f"• По МСК: `{nearest_time.strftime('%d.%m.%Y %H:%M')}`\n"
|
||
f"• Локально: <t:{create_ts}:F>\n\n"
|
||
f"🎯 **Начало события:**\n"
|
||
f"• По МСК: `{start_time.strftime('%d.%m.%Y %H:%M')}`\n"
|
||
f"• Локально: <t:{start_ts}:F>\n\n"
|
||
f"🔑 key: `{nearest.get('key', '—')}`"
|
||
),
|
||
inline=False
|
||
)
|
||
else:
|
||
embed.add_field(
|
||
name="⏭ Ближайшее автосоздание",
|
||
value="Будущие автособытия не найдены.",
|
||
inline=False
|
||
)
|
||
|
||
embed.set_footer(text="Команда: !автоивенты_статус")
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🆘 [БЛОК 8] Команда помощи и обработка ошибок
|
||
# ================================================
|
||
|
||
# 📘 Команда: !хелп (и алиас !помоги)
|
||
@bot.command(name="хелп", aliases=["помоги", "команды"])
|
||
async def help_command(ctx):
|
||
embed = disnake.Embed(
|
||
title="📘 Команды бота",
|
||
description="Список доступных команд:",
|
||
color=disnake.Color.blue()
|
||
)
|
||
|
||
# 📊 Общие команды
|
||
embed.add_field(name="📌 Основные", value="\u200b", inline=False)
|
||
embed.add_field(name="`!хелп` / `!помоги` / `!команды`", value="Показать это сообщение.", inline=False)
|
||
embed.add_field(name="`!рандом <мин> <макс>`", value="Случайное число в диапазоне.", inline=False)
|
||
|
||
# 🎖 Ранги и опыт
|
||
embed.add_field(name="🎖 Ранги и опыт", value="\u200b", inline=False)
|
||
embed.add_field(name="`!ранг` / `!ранг @пользователь`", value="Показать ранг, уровень и срок службы.", inline=False)
|
||
embed.add_field(name="`!топ`", value="Топ-10 бойцов по опыту.", inline=False)
|
||
embed.add_field(name="`!добавить_опыт @пользователь <число>`", value="Добавить опыт (для Военкомата).", inline=False)
|
||
embed.add_field(name="`!отнять_опыт @пользователь <число>`", value="Убрать опыт (для Военкомата).", inline=False)
|
||
embed.add_field(name="`!сброс_всё`", value="Полный сброс всех данных (для Военкомата).", inline=False)
|
||
|
||
# 📅 Ивенты
|
||
embed.add_field(name="📅 Ивенты", value="\u200b", inline=False)
|
||
embed.add_field(name="`!ивент`", value="Показать список предстоящих ивентов (для Отрядного игрока).", inline=False)
|
||
embed.add_field(name="`!ивент_отчёт <ID/название>`", value="Показать отчёт по участникам события (для Штаба).", inline=False)
|
||
embed.add_field(name="`!ивент_удалить <ID/название>`", value="Удалить событие и отменить задачи (для Штаба).", inline=False)
|
||
embed.add_field(name="`!ивент_восстановить <Название | Дата>`", value="Восстоновить ивент (для Штаба).", inline=False)
|
||
embed.add_field(name="`!создать_голосования`", value="🔄 Запуск создания всех голосований по расписанию(для Штаба).",inline=False)
|
||
|
||
# ⚙️ Автоивенты (scheduler)
|
||
embed.add_field(name="⚙️ Автоивенты (scheduler)", value="\u200b", inline=False)
|
||
embed.add_field(name="`!автоивенты_вкл`", value="🟢 Включить автоматическое создание голосований по расписанию (для Штаба).", inline=False)
|
||
embed.add_field(name="`!автоивенты_выкл`", value="⛔ Отключить автоматическое создание голосований по расписанию (для Штаба).", inline=False)
|
||
embed.add_field(name="`!автоивенты_статус`", value="📊 Показать статус автосоздания (для Штаба).", inline=False)
|
||
|
||
# 📝 Примечание
|
||
embed.add_field(
|
||
name="📝 Примечание",
|
||
value="Автосоздание — это цикл `scheduled_event_loop` в `scheduler.py`. Команды выше только управляют его запуском/остановкой.",
|
||
inline=False
|
||
)
|
||
|
||
# 🧹 Управление сообщениями
|
||
embed.add_field(name="🧹 Очистка", value="\u200b", inline=False)
|
||
embed.add_field(name="`!удалить <кол-во>`", value="Удалить сообщения (для админов).", inline=False)
|
||
embed.add_field(name="`!удалить_все`", value="Очистить весь канал (для админов).", inline=False)
|
||
|
||
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
# ================================================
|
||
# 🧪 [ТЕСТ] Команда для проверки отображения аватарки бота в embed
|
||
# ================================================
|
||
|
||
@bot.command(name="тест_embed")
|
||
async def test_embed(ctx):
|
||
# 📦 Создаём embed через универсальную функцию create_embed
|
||
embed = create_embed(
|
||
title="📦 Тестовое сообщение",
|
||
description="Если ты видишь аватарку бота сверху — всё работает правильно!",
|
||
color=disnake.Color.purple()
|
||
)
|
||
|
||
# ➕ Устанавливаем футер с ником и аватаркой пользователя, вызвавшего команду
|
||
embed.set_footer(text=f"Команду вызвал: {ctx.author}", icon_url=ctx.author.display_avatar.url)
|
||
|
||
# 📤 Отправляем embed в канал
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
# ❌ Логирование ошибок
|
||
@bot.event
|
||
async def on_command_error(ctx, error):
|
||
embed = disnake.Embed(
|
||
title="🚨 Ошибка команды",
|
||
description=f"**Команда:** `{ctx.message.content}`\n**Ошибка:** `{str(error)}`",
|
||
color=disnake.Color.red()
|
||
)
|
||
embed.set_footer(text=f"Пользователь: {ctx.author} • ID: {ctx.author.id}")
|
||
|
||
# 📨 Отправка в тот канал, где вызвали команду
|
||
try:
|
||
await ctx.send(embed=embed)
|
||
except disnake.Forbidden:
|
||
pass # если нет прав отправить сообщение в канал
|
||
|
||
# 📨 Отправка в канал логов
|
||
channel = bot.get_channel(ERROR_LOG_CHANNEL_ID)
|
||
if channel:
|
||
try:
|
||
await channel.send(embed=embed)
|
||
except disnake.Forbidden:
|
||
print("❌ Нет прав отправить лог ошибки в лог-канал.")
|
||
else:
|
||
print(f"Ошибка команды: {ctx.message.content} -> {error}")
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🆘 [БЛОК 9] Команда рандом
|
||
# ================================================
|
||
|
||
@bot.command(name="рандом")
|
||
async def random_number(ctx, min_number: int, max_number: int):
|
||
if min_number >= max_number:
|
||
await ctx.send("Минимальное число не может быть больше или равно максимальному!")
|
||
return
|
||
|
||
number = random.randint(min_number, max_number)
|
||
|
||
embed = disnake.Embed(
|
||
title="🎲 Случайное число",
|
||
description=f"Ваше число: **{number}** из **{min_number}–{max_number}**",
|
||
color=disnake.Color.random()
|
||
)
|
||
embed.set_author(
|
||
name=f"{ctx.author.display_name}",
|
||
icon_url=ctx.author.display_avatar.url
|
||
)
|
||
embed.set_footer(text="Команда: !рандом <мин. число> <макс. число>")
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
# 🧠 Временное хранилище информации по каждому ивенту (ID → инфо, голоса и пр.)
|
||
event_tasks = {}
|
||
|
||
# 📥 Загрузка событий из файла
|
||
def load_event_tasks():
|
||
if os.path.exists(EVENTS_FILE):
|
||
with open(EVENTS_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
for event_id, value in data.items():
|
||
dt = datetime.datetime.fromisoformat(value["event"]["start_time"])
|
||
if dt.tzinfo is None:
|
||
dt = MOSCOW_TZ.localize(dt)
|
||
value["event"]["start_time"] = dt
|
||
return data
|
||
return {}
|
||
|
||
|
||
|
||
def save_event_tasks():
|
||
to_save = {}
|
||
for event_id, value in event_tasks.items():
|
||
to_save[event_id] = {
|
||
"event": {
|
||
"name": value["event"]["name"],
|
||
"start_time": value["event"]["start_time"].isoformat()
|
||
},
|
||
"votes": value["votes"],
|
||
"poll_msg_id": value["poll_msg_id"]
|
||
}
|
||
with open(EVENTS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(to_save, f, ensure_ascii=False, indent=2)
|
||
|
||
event_tasks = load_event_tasks()
|
||
scheduled_tasks = {}
|
||
|
||
# ✅ Фиксируем tzinfo, если отсутствует
|
||
def fix_event_timezones():
|
||
changed = False
|
||
for event_id, value in event_tasks.items():
|
||
dt = value["event"]["start_time"]
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=MOSCOW_TZ)
|
||
value["event"]["start_time"] = dt
|
||
changed = True
|
||
if changed:
|
||
save_event_tasks()
|
||
print("✅ Все события были обновлены с tzinfo.")
|
||
|
||
fix_event_timezones()
|
||
|
||
# --------------------------------------
|
||
# 🧩 Парсинг названия и даты события из текста сообщения
|
||
# --------------------------------------
|
||
def parse_event_info(message_content):
|
||
if "[VLNKI]" not in message_content:
|
||
return None # Пропускаем сообщения без маркера
|
||
|
||
# Ищем дату: 09.06.2025 или 09/06/2025
|
||
date_match = re.search(r"(\d{2})[.\-/](\d{2})[.\-/](\d{4})[гГ]?", message_content)
|
||
if not date_match:
|
||
return None
|
||
|
||
day, month, year = map(int, date_match.groups())
|
||
|
||
# Ищем ВРЕМЯ по МСК (например, 15.32 по МСК)
|
||
time_match = re.search(r"(\d{1,2})[.:](\d{2})\s*по\s*МСК", message_content, re.IGNORECASE)
|
||
if not time_match:
|
||
return None
|
||
|
||
hour, minute = map(int, time_match.groups())
|
||
|
||
# 🔍 Определение названия события по ключевым словам
|
||
upper_text = message_content.upper()
|
||
if "TACTIC TVT EVENT" in message_content:
|
||
name = "ECHO TTvT"
|
||
elif "ТВТ" in upper_text:
|
||
name = "ECHO TvT"
|
||
else:
|
||
title_match = re.search(r"(ECHO\s*\|[^\n]+)", message_content, re.IGNORECASE)
|
||
name = title_match.group(1).strip() if title_match else "ECHO Unknown"
|
||
|
||
dt = datetime.datetime(year, month, day, hour, minute)
|
||
|
||
return {
|
||
"name": name,
|
||
"start_time": dt
|
||
}
|
||
|
||
# --------------------------------------
|
||
# 📊 Создание голосования на основе события ECHO
|
||
# --------------------------------------
|
||
def generate_event_id(name: str, start_time: datetime.datetime) -> str:
|
||
name = name.lower().replace(" ", "").replace("-", "").replace("_", "")
|
||
if "echo" in name:
|
||
prefix = "echottvt" if "ttvt" in name else "echotvt"
|
||
elif "redbear" in name or "red" in name:
|
||
prefix = "redbertvt"
|
||
else:
|
||
prefix = "event"
|
||
|
||
date_str = start_time.strftime("%Y%m%d_%H%M")
|
||
return f"{prefix}{date_str}"
|
||
|
||
async def create_poll(event_data, message_id, is_restore=False):
|
||
guild = bot.get_guild(GUILD_ID)
|
||
if guild is None:
|
||
print("Гильдия не найдена!")
|
||
return
|
||
|
||
channel = guild.get_channel(POLL_CHANNEL_ID)
|
||
if channel is None:
|
||
print("Канал для опроса не найден!")
|
||
return
|
||
|
||
name = event_data["name"]
|
||
start_time = event_data["start_time"]
|
||
|
||
# Генерация ID события на основе названия и времени
|
||
event_id = generate_event_id(name, start_time)
|
||
|
||
dt = event_data["start_time"] # Время в МСК
|
||
from pytz import timezone, utc
|
||
msk = timezone("Europe/Moscow")
|
||
start_time_utc = start_time.astimezone(utc) # переводим в UTC
|
||
timestamp = int(start_time_utc.timestamp()) # для Discord <t:...>
|
||
formatted_time = dt.strftime("%d.%m.%Y %H:%M")
|
||
|
||
# Обновляем название с датой
|
||
if not is_restore:
|
||
formatted_time = event_data["start_time"].strftime("%d.%m.%Y %H:%M")
|
||
event_data["name"] += f" ({formatted_time})"
|
||
|
||
choices = {
|
||
"✅": "Да, пойду",
|
||
"❌": "Нет, не пойду",
|
||
"🤔": "50/50"
|
||
}
|
||
votes = {emoji: [] for emoji in choices}
|
||
view = disnake.ui.View(timeout=None)
|
||
|
||
from pytz import timezone, utc
|
||
|
||
def build_poll_embed():
|
||
"""
|
||
🔁 Обновлённый Embed для опроса: показывает **никнеймы голосующих** вместо процентов.
|
||
🕒 Время по МСК и <t:timestamp> по локальному времени сохраняется.
|
||
"""
|
||
# 📅 Время начала события
|
||
start_time_msk = to_msk(event_data["start_time"] if "start_time" in event_data else event["start_time"])
|
||
start_time_utc = start_time_msk.astimezone(pytz.utc)
|
||
timestamp = int(start_time_utc.timestamp())
|
||
|
||
formatted_msk = start_time_msk.strftime("%d.%m.%Y %H:%M")
|
||
|
||
# 📝 Основной текст
|
||
description = (
|
||
f"🕒 Начало по МСК: {formatted_msk}\n"
|
||
f"🌍 По вашему времени: <t:{timestamp}:f>\n\n"
|
||
)
|
||
|
||
for emoji, label in choices.items():
|
||
mentions = []
|
||
for uid in votes[emoji]:
|
||
member = bot.get_guild(GUILD_ID).get_member(uid)
|
||
if member:
|
||
mentions.append(member.display_name)
|
||
name_list = "\n".join(mentions) if mentions else "—"
|
||
description += f"{emoji} **{label}** \n {name_list}\n"
|
||
|
||
embed = disnake.Embed(
|
||
title=f"📆 {event_data['name'] if 'name' in event_data else event['name']}",
|
||
description=description,
|
||
color=disnake.Color.green()
|
||
)
|
||
embed.set_footer(text="Нажмите кнопку, чтобы проголосовать.")
|
||
return embed
|
||
|
||
async def button_callback(interaction: disnake.MessageInteraction, emoji):
|
||
for em in votes:
|
||
if interaction.user.id in votes[em]:
|
||
votes[em].remove(interaction.user.id)
|
||
votes[emoji].append(interaction.user.id)
|
||
|
||
event_tasks[event_id]["votes"] = votes
|
||
save_event_tasks()
|
||
|
||
await interaction.response.send_message(f"Вы выбрали: {choices[emoji]}", ephemeral=True)
|
||
await interaction.message.edit(embed=build_poll_embed(), view=view)
|
||
|
||
for emoji, label in choices.items():
|
||
button = disnake.ui.Button(label=label, emoji=emoji, style=disnake.ButtonStyle.primary)
|
||
button.callback = lambda inter, e=emoji: asyncio.create_task(button_callback(inter, e))
|
||
view.add_item(button)
|
||
|
||
mentions = f"<@&{ROLE_PLAYER_ID}> <@&{VLNKI_ROLE_ID}>"
|
||
poll_msg = await channel.send(content=mentions, embed=build_poll_embed(), view=view)
|
||
|
||
event_tasks[event_id] = {
|
||
"event": event_data,
|
||
"votes": votes,
|
||
"poll_msg_id": poll_msg.id
|
||
}
|
||
save_event_tasks()
|
||
|
||
# ⏱ Планирование напоминаний и отчётов
|
||
await schedule_reminders(event_id)
|
||
|
||
# 🔁 Восстановление кнопок для голосования при перезапуске
|
||
|
||
async def rebind_poll_buttons(event_id):
|
||
data = event_tasks.get(event_id)
|
||
if not data:
|
||
print(f"⚠️ Не найдено событие с ID {event_id} для восстановления кнопок.")
|
||
return
|
||
|
||
event_data = data["event"] # 🔄 заменили: теперь можно использовать event_data
|
||
votes = data["votes"]
|
||
msg_id = data["poll_msg_id"]
|
||
|
||
guild = bot.get_guild(GUILD_ID)
|
||
if not guild:
|
||
print("❌ Гильдия не найдена!")
|
||
return
|
||
channel = guild.get_channel(POLL_CHANNEL_ID)
|
||
if not channel:
|
||
print("❌ Канал для опросов не найден!")
|
||
return
|
||
|
||
try:
|
||
poll_msg = await channel.fetch_message(msg_id)
|
||
except Exception as e:
|
||
logging.exception("Не удалось получить сообщение ID:")
|
||
print(f"⚠️ Не удалось получить сообщение ID {msg_id}: {e}")
|
||
return
|
||
|
||
choices = {
|
||
"✅": "Да, пойду",
|
||
"❌": "Нет, не пойду",
|
||
"🤔": "50/50"
|
||
}
|
||
|
||
# 🛠️ build_poll_embed — обновлённая версия с отображением ников
|
||
def build_poll_embed():
|
||
"""
|
||
🔁 Обновлённый Embed для опроса: показывает имена голосующих.
|
||
"""
|
||
start_time_msk = to_msk(event_data["start_time"])
|
||
start_time_utc = start_time_msk.astimezone(pytz.utc)
|
||
timestamp = int(start_time_utc.timestamp())
|
||
|
||
formatted_msk = start_time_msk.strftime("%d.%m.%Y %H:%M")
|
||
description = (
|
||
f"🕒 Начало по МСК: {formatted_msk}\n"
|
||
f"🌍 По вашему времени: <t:{timestamp}:f>\n\n"
|
||
)
|
||
|
||
for emoji, label in choices.items():
|
||
mentions = []
|
||
for uid in votes[emoji]:
|
||
member = bot.get_guild(GUILD_ID).get_member(uid)
|
||
if member:
|
||
mentions.append(member.display_name)
|
||
name_list = "\n".join(mentions) if mentions else "—"
|
||
description += f"{emoji} **{label}** \n {name_list}\n"
|
||
|
||
embed = disnake.Embed(
|
||
title=f"📆 {event_data['name']}",
|
||
description=description,
|
||
color=disnake.Color.green()
|
||
)
|
||
embed.set_footer(text="Нажмите кнопку, чтобы проголосовать.")
|
||
return embed
|
||
|
||
view = disnake.ui.View(timeout=None)
|
||
|
||
async def button_callback(interaction: disnake.MessageInteraction, emoji):
|
||
for em in votes:
|
||
if interaction.user.id in votes[em]:
|
||
votes[em].remove(interaction.user.id)
|
||
votes[emoji].append(interaction.user.id)
|
||
event_tasks[event_id]["votes"] = votes
|
||
save_event_tasks()
|
||
await interaction.response.send_message(f"Вы выбрали: {choices[emoji]}", ephemeral=True)
|
||
await interaction.message.edit(embed=build_poll_embed(), view=view)
|
||
|
||
for emoji, label in choices.items():
|
||
button = disnake.ui.Button(label=label, emoji=emoji, style=disnake.ButtonStyle.primary)
|
||
button.callback = lambda inter, e=emoji: asyncio.create_task(button_callback(inter, e))
|
||
view.add_item(button)
|
||
|
||
await poll_msg.edit(embed=build_poll_embed(), view=view)
|
||
print(f"🔁 Кнопки восстановлены для события {event_data['name']}")
|
||
|
||
|
||
|
||
|
||
# === View для кнопок голосования ===
|
||
class PollView(disnake.ui.View):
|
||
def __init__(self):
|
||
super().__init__(timeout=None)
|
||
self.votes = {"✅": set(), "❌": set(), "🤔": set()}
|
||
|
||
async def handle_vote(self, inter, choice):
|
||
user = inter.author
|
||
# удаляем прошлый выбор
|
||
for key in self.votes:
|
||
self.votes[key].discard(user.id)
|
||
# добавляем новый
|
||
self.votes[choice].add(user.id)
|
||
|
||
await inter.response.send_message(f"Вы выбрали: **{choice}**", ephemeral=True)
|
||
await self.update_message(inter.message)
|
||
|
||
async def update_message(self, message):
|
||
desc = ""
|
||
for emoji, users in self.votes.items():
|
||
if users:
|
||
mentions = [f"<@{u}>" for u in users]
|
||
desc += f"{emoji} {' '.join(mentions)}\n"
|
||
else:
|
||
desc += f"{emoji} —\n"
|
||
embed = message.embeds[0]
|
||
embed.description = embed.description.split("\n\n---")[0] + f"\n\n---\n{desc}"
|
||
await message.edit(embed=embed, view=self)
|
||
|
||
@disnake.ui.button(label="✅ Да, пойду", style=disnake.ButtonStyle.success, custom_id="vote_yes")
|
||
async def yes_button(self, button, inter):
|
||
await self.handle_vote(inter, "✅")
|
||
|
||
@disnake.ui.button(label="❌ Нет, не пойду", style=disnake.ButtonStyle.danger, custom_id="vote_no")
|
||
async def no_button(self, button, inter):
|
||
await self.handle_vote(inter, "❌")
|
||
|
||
@disnake.ui.button(label="🤔 50/50", style=disnake.ButtonStyle.secondary, custom_id="vote_maybe")
|
||
async def maybe_button(self, button, inter):
|
||
await self.handle_vote(inter, "🤔")
|
||
|
||
|
||
|
||
# --------------------------------------
|
||
# ⏱ Планирование напоминаний и отчётов
|
||
# --------------------------------------
|
||
async def schedule_reminders(event_id):
|
||
event = event_tasks[event_id]["event"]
|
||
votes = event_tasks[event_id]["votes"]
|
||
start_time = event["start_time"]
|
||
name = event["name"]
|
||
|
||
if event_id in scheduled_tasks:
|
||
cancel_scheduled_tasks(event_id)
|
||
|
||
|
||
# 🔍 Получение объектов участников по ID
|
||
async def get_members_from_ids(ids):
|
||
guild = bot.get_guild(GUILD_ID)
|
||
return [guild.get_member(uid) for uid in ids if guild.get_member(uid)]
|
||
|
||
# 🔔 Напоминание за N минут
|
||
async def send_embed_reminder(minutes_before: int, start_time: datetime.datetime, name: str, votes: dict):
|
||
# Убедимся, что start_time локализован
|
||
if start_time.tzinfo is None:
|
||
start_time = to_msk(start_time)
|
||
|
||
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
delay = (start_time - now).total_seconds() - minutes_before * 60
|
||
await asyncio.sleep(max(0, round(delay))) # Спим до нужного момента
|
||
|
||
# Получаем список участников
|
||
voted_yes_ids = votes.get("✅", [])
|
||
voted_maybe_ids = votes.get("🤔", [])
|
||
yes_members = await get_members_from_ids(voted_yes_ids)
|
||
maybe_members = await get_members_from_ids(voted_maybe_ids)
|
||
yes_list = ", ".join(m.mention for m in yes_members) or "—"
|
||
maybe_list = ", ".join(m.mention for m in maybe_members) or "—"
|
||
|
||
embed = disnake.Embed(
|
||
title="⏰ Напоминание о событии",
|
||
description=(
|
||
f"**Название:** {name}\n"
|
||
f"**Московское время:** {start_time.strftime('%d.%m.%Y %H:%M')} по МСК\n"
|
||
f"**Ваше местное время:** <t:{int(start_time.timestamp())}:F>\n"
|
||
f"**До начала осталось:** {minutes_before} минут"
|
||
),
|
||
color=disnake.Color.orange()
|
||
)
|
||
|
||
# Добавляем в embed список проголосовавших
|
||
embed.add_field(name="✅ Идут", value=yes_list, inline=False)
|
||
embed.add_field(name="🤔 50/50", value=maybe_list, inline=False)
|
||
embed.set_footer(text="Событие начнётся в точное время. Не опаздывай!")
|
||
|
||
# Отправляем embed в канал по ID
|
||
await bot.get_channel(REMINDER_CHANNEL_ID).send(embed=embed)
|
||
|
||
# 🧹 Удаление события через 7 дней после начала
|
||
async def delete_expired_event():
|
||
now_local = datetime.datetime.now(MOSCOW_TZ)
|
||
delay = (start_time - now_local).total_seconds() + 7 * 86000
|
||
await asyncio.sleep(max(0, round(delay)))
|
||
|
||
# Удаление сообщения
|
||
try:
|
||
poll_msg_id = event_tasks.get(event_id, {}).get("poll_msg_id")
|
||
channel = bot.get_channel(POLL_CHANNEL_ID)
|
||
|
||
if not poll_msg_id or channel is None:
|
||
raise RuntimeError(
|
||
f"Нет poll_msg_id или не найден канал (poll_msg_id={poll_msg_id}, channel={channel})")
|
||
|
||
msg = await channel.fetch_message(poll_msg_id)
|
||
await msg.delete()
|
||
|
||
except Exception as e:
|
||
logging.exception("Ошибка при удалении сообщения:")
|
||
print(f"⚠️ Ошибка при удалении сообщения: {e}")
|
||
|
||
# Удаление из памяти + файла
|
||
event_tasks.pop(event_id, None)
|
||
save_event_tasks()
|
||
|
||
print(f"🗑️ Событие '{name}' автоматически удалено через 6ч после начала.")
|
||
|
||
# 🧠 Планируем все задачи
|
||
task1 = asyncio.create_task(send_embed_reminder(120, start_time, name, votes)) # За 2 часа
|
||
task2 = asyncio.create_task(send_embed_reminder(30, start_time, name, votes)) # За 30 минут
|
||
task3 = asyncio.create_task(delete_expired_event()) # Через 6 часов удаление
|
||
scheduled_tasks[event_id] = [task1, task2, task3]
|
||
|
||
# 🔴 Отмена всех задач, связанных с ивентом
|
||
def cancel_scheduled_tasks(event_id):
|
||
tasks = scheduled_tasks.get(event_id)
|
||
if tasks:
|
||
for task in tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
print(f"❌ Отменены задачи для ивента {event_id}")
|
||
scheduled_tasks.pop(event_id, None)
|
||
|
||
|
||
|
||
|
||
|
||
# --------------------------------------
|
||
# 📜 Команда @bot.command(name="ивент")
|
||
@bot.command(name="ивент")
|
||
@commands.has_role(ROLE_PLAYER_ID)
|
||
async def upcoming_events(ctx):
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
|
||
# 🛠 Защита: приводим все даты к timezone-aware
|
||
for data in event_tasks.values():
|
||
dt = data["event"]["start_time"]
|
||
if dt.tzinfo is None:
|
||
data["event"]["start_time"] = to_msk(dt)
|
||
|
||
upcoming = [
|
||
(event_id, data) for event_id, data in event_tasks.items()
|
||
if data["event"]["start_time"] > now
|
||
]
|
||
|
||
# ✅ Создаём embed заранее
|
||
embed = disnake.Embed(title="📅 Предстоящие события", color=disnake.Color.green())
|
||
|
||
if not upcoming:
|
||
embed.description = "📭 Нет предстоящих событий."
|
||
await ctx.send(embed=embed)
|
||
return
|
||
|
||
for event_id, data in upcoming:
|
||
dt = data["event"]["start_time"]
|
||
formatted = dt.strftime("%d.%m.%Y в %H:%M")
|
||
|
||
# 📝 Название + дата и время
|
||
title = f"{data['event']['name']} — {formatted}"
|
||
|
||
# 🔎 ID ивента
|
||
id_line = f"> 🆔 ID: `{event_id}`"
|
||
|
||
# 🕒 <t:...> — локальное время для пользователя
|
||
time_line = f"🕒 <t:{int(dt.timestamp())}:F>"
|
||
|
||
embed.add_field(
|
||
name=title,
|
||
value=f"{time_line}\n{id_line}",
|
||
inline=False
|
||
)
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
@bot.command(name="ивент_удалить")
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def delete_event(ctx, *, query: str):
|
||
# 🔍 Попытка найти ивент по ID или части названия
|
||
matched_id = None
|
||
for event_id, data in event_tasks.items():
|
||
if query == event_id or query.lower() in data["event"]["name"].lower():
|
||
matched_id = event_id
|
||
break
|
||
|
||
if not matched_id:
|
||
await ctx.send(f"❌ Ивент с ID или названием `{query}` не найден.")
|
||
return
|
||
|
||
# Отмена задач
|
||
for task in scheduled_tasks.get(matched_id, []):
|
||
task.cancel()
|
||
scheduled_tasks.pop(matched_id, None)
|
||
|
||
# Удаление сообщения
|
||
guild = bot.get_guild(GUILD_ID)
|
||
if not guild:
|
||
await ctx.send("❌ Не удалось получить гильдию.")
|
||
return
|
||
|
||
channel = guild.get_channel(POLL_CHANNEL_ID)
|
||
if not channel:
|
||
await ctx.send("❌ Не удалось получить канал с опросами.")
|
||
return
|
||
|
||
poll_msg_id = event_tasks[matched_id].get("poll_msg_id")
|
||
try:
|
||
msg = await channel.fetch_message(poll_msg_id)
|
||
await msg.delete()
|
||
except Exception as e:
|
||
logging.exception("Не удалось удалить сообщение:")
|
||
print(f"⚠️ Не удалось удалить сообщение: {e}")
|
||
|
||
# Удаление из памяти
|
||
event_tasks.pop(matched_id)
|
||
|
||
# Сохраняем
|
||
if "save_event_tasks" in globals():
|
||
save_event_tasks()
|
||
else:
|
||
with open(EVENTS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump({
|
||
k: {
|
||
"event": {
|
||
"name": v["event"]["name"],
|
||
"start_time": v["event"]["start_time"].isoformat()
|
||
},
|
||
"poll_msg_id": v["poll_msg_id"]
|
||
} for k, v in event_tasks.items()
|
||
}, f, ensure_ascii=False, indent=2)
|
||
|
||
# Ответ пользователю
|
||
embed = disnake.Embed(
|
||
title="🗑 Ивент удалён",
|
||
description=f"Ивент `{matched_id}` был удалён и все задачи отменены.",
|
||
color=disnake.Color.red()
|
||
)
|
||
await ctx.send(embed=embed)
|
||
|
||
@bot.command(name="ивент_отчёт")
|
||
@commands.has_role(ROLE_SHTAB_ID)
|
||
async def event_report(ctx, *, event_query: str):
|
||
found = None
|
||
query = event_query.strip().lower()
|
||
|
||
# 🔍 Попытка распарсить "Название — 20.06.2025 в 19:30"
|
||
pattern_full = re.search(
|
||
r"(.+?)[—-]\s*(\d{2})\.(\d{2})\.(\d{4})\s*в\s*(\d{1,2}):(\d{2})",
|
||
query
|
||
)
|
||
if pattern_full:
|
||
name_part = pattern_full.group(1).strip()
|
||
day, month, year = map(int, pattern_full.group(2, 3, 4))
|
||
hour, minute = map(int, pattern_full.group(5, 6))
|
||
dt = datetime.datetime(year, month, day, hour, minute)
|
||
|
||
for event_id, data in event_tasks.items():
|
||
if name_part.lower() in data["event"]["name"].lower() and data["event"]["start_time"] == dt:
|
||
found = (event_id, data)
|
||
break
|
||
|
||
# 🔍 Если это только дата "20.06.2025" или "20.06.2025 в 19:30"
|
||
if not found:
|
||
pattern_date = re.search(
|
||
r"(\d{2})\.(\d{2})\.(\d{4})(?:\s*в\s*(\d{1,2}):(\d{2}))?",
|
||
query
|
||
)
|
||
if pattern_date:
|
||
day, month, year = map(int, pattern_date.group(1, 2, 3))
|
||
if pattern_date.group(4): # есть время
|
||
hour = int(pattern_date.group(4))
|
||
minute = int(pattern_date.group(5))
|
||
dt = datetime.datetime(year, month, day, hour, minute)
|
||
for event_id, data in event_tasks.items():
|
||
if data["event"]["start_time"] == dt:
|
||
found = (event_id, data)
|
||
break
|
||
else: # без времени
|
||
for event_id, data in event_tasks.items():
|
||
st = data["event"]["start_time"]
|
||
if st.date() == datetime.date(year, month, day):
|
||
found = (event_id, data)
|
||
break
|
||
|
||
# 🔍 Поиск по ID или части названия
|
||
if not found:
|
||
for event_id, data in event_tasks.items():
|
||
if query in event_id.lower() or query in data["event"]["name"].lower():
|
||
found = (event_id, data)
|
||
break
|
||
|
||
# ❌ Если ничего не нашли
|
||
if not found:
|
||
await ctx.send("❌ Ивент не найден.")
|
||
return
|
||
|
||
# ✅ Формируем отчёт
|
||
event_id, data = found
|
||
name = data["event"]["name"]
|
||
votes = data["votes"]
|
||
|
||
lines = []
|
||
for emoji, ids in votes.items():
|
||
members = [bot.get_guild(GUILD_ID).get_member(uid) for uid in ids]
|
||
names = ', '.join(m.display_name for m in members if m) or "—"
|
||
lines.append(f"**{emoji}** — {names}")
|
||
|
||
embed = disnake.Embed(
|
||
title=f"📊 Отчёт по ивенту: {name}",
|
||
description="\n".join(lines),
|
||
color=disnake.Color.blurple()
|
||
)
|
||
await ctx.send(embed=embed)
|
||
|
||
|
||
from disnake.ext import commands
|
||
|
||
|
||
|
||
@bot.command(name="ивент_проверить")
|
||
@commands.has_permissions(administrator=True) # Можно ограничить доступ, если нужно
|
||
async def manual_rss_check(ctx):
|
||
await ctx.send("🔄 Проверка RSS на новые ивенты началась...")
|
||
|
||
try:
|
||
# Принудительный вызов check_rss_loop, который должен быть обычной async-функцией
|
||
await check_rss_once()
|
||
await ctx.send("✅ Проверка завершена.")
|
||
except Exception as e:
|
||
logging.exception("Ошибка при проверке RSS:")
|
||
await ctx.send(f"❌ Ошибка при проверке RSS: `{e}`")
|
||
|
||
|
||
|
||
# 🎯 Здесь укажи ID канала, куда бот будет отправлять собранные тикеты
|
||
TICKET_CHANNEL_ID = 1391489515389976658 # Канал отправление тикетов
|
||
|
||
# ================================
|
||
# 🎛️ Кнопки одобрения / отклонения
|
||
# ================================
|
||
class ApproveRejectButtons(disnake.ui.View):
|
||
def __init__(self, applicant_id: int):
|
||
super().__init__(timeout=None)
|
||
self.applicant_id = applicant_id
|
||
|
||
@disnake.ui.button(
|
||
label="Одобрить",
|
||
emoji="✅",
|
||
style=disnake.ButtonStyle.success,
|
||
custom_id="approve_ticket"
|
||
)
|
||
async def approve(self, button: disnake.ui.Button, inter: disnake.MessageInteraction):
|
||
await self._notify_applicant(
|
||
inter,
|
||
title="✅ Ваша анкета была одобрена, добро пожаловать!",
|
||
colour=disnake.Color.green()
|
||
)
|
||
|
||
@disnake.ui.button(
|
||
label="Отклонить",
|
||
emoji="❌",
|
||
style=disnake.ButtonStyle.danger,
|
||
custom_id="reject_ticket"
|
||
)
|
||
async def reject(self, button: disnake.ui.Button, inter: disnake.MessageInteraction):
|
||
await self._notify_applicant(
|
||
inter,
|
||
title="❌ Ваша анкета была отклонена,\nсвяжитесь с [VLNKI] Jeronimo (Kolumb), чтобы узнать подробности!",
|
||
colour=disnake.Color.red()
|
||
)
|
||
|
||
async def _notify_applicant(self, inter: disnake.MessageInteraction, *, title: str, colour: disnake.Color):
|
||
bot = inter.client
|
||
user = inter.guild.get_member(self.applicant_id) or bot.get_user(self.applicant_id)
|
||
|
||
# Попробовать загрузить пользователя напрямую, если не найден
|
||
if user is None:
|
||
try:
|
||
user = await bot.fetch_user(self.applicant_id)
|
||
except disnake.NotFound:
|
||
await inter.response.send_message(
|
||
f"⚠️ Не удалось найти пользователя с ID `{self.applicant_id}`.",
|
||
ephemeral=True
|
||
)
|
||
return
|
||
except Exception as e:
|
||
logging.exception("Ошибка при получении пользователя:")
|
||
await inter.response.send_message(
|
||
f"❌ Ошибка при получении пользователя: {e}",
|
||
ephemeral=True
|
||
)
|
||
return
|
||
|
||
# Подготовка embed-сообщения
|
||
embed = disnake.Embed(
|
||
title=title,
|
||
color=colour,
|
||
timestamp=datetime.datetime.now(MOSCOW_TZ)
|
||
)
|
||
|
||
try:
|
||
await user.send(embed=embed)
|
||
except disnake.Forbidden:
|
||
await inter.response.send_message(
|
||
f"⚠️ Не удалось отправить сообщение {user.mention} — у него закрыты ЛС.",
|
||
ephemeral=True
|
||
)
|
||
return
|
||
except Exception as e:
|
||
logging.exception("Ошибка при отправке сообщения:")
|
||
await inter.response.send_message(
|
||
f"❌ Ошибка при отправке сообщения: {e}",
|
||
ephemeral=True
|
||
)
|
||
return
|
||
|
||
# Всё прошло успешно — закрываем тикет
|
||
await inter.response.send_message("✅ Ответ успешно отправлен в ЛС.", ephemeral=True)
|
||
|
||
for child in self.children:
|
||
child.disabled = True
|
||
await inter.message.edit(view=self)
|
||
|
||
tickets[str(inter.message.id)]["resolved"] = True
|
||
save_tickets()
|
||
|
||
# =========================
|
||
# 📌 Модальное окно тикета
|
||
# =========================
|
||
class TicketModal(disnake.ui.Modal):
|
||
def __init__(self):
|
||
components = [
|
||
disnake.ui.TextInput(
|
||
label="Кто проводил собеседование",
|
||
placeholder="Укажите имя проверяющего",
|
||
custom_id="interviewer",
|
||
required=True,
|
||
style=disnake.TextInputStyle.short
|
||
),
|
||
disnake.ui.TextInput(
|
||
label="Ник в игре",
|
||
placeholder="Ваш ник на Discord сервера как в Steam",
|
||
custom_id="ingame_nick",
|
||
required=True,
|
||
style=disnake.TextInputStyle.short
|
||
),
|
||
disnake.ui.TextInput(
|
||
label="Ник на сайте AS VDV",
|
||
placeholder="Ваш ник на https://asvdv.ru",
|
||
custom_id="asvdv_nick",
|
||
required=True,
|
||
style=disnake.TextInputStyle.short
|
||
),
|
||
disnake.ui.TextInput(
|
||
label="Ник на сайте ECHO",
|
||
placeholder="Ваш ник на https://echo-sga.ru",
|
||
custom_id="echo_nick",
|
||
required=True,
|
||
style=disnake.TextInputStyle.short
|
||
),
|
||
disnake.ui.TextInput(
|
||
label="Часовой пояс",
|
||
placeholder="Например: UTC+3 (Москва) или UTC+5 (Екатеринбург)",
|
||
custom_id="timezone",
|
||
required=True,
|
||
style=disnake.TextInputStyle.short
|
||
)
|
||
]
|
||
|
||
super().__init__(title="📨 Форма собеседования", components=components)
|
||
|
||
async def callback(self, inter: disnake.ModalInteraction):
|
||
interviewer = inter.text_values["interviewer"]
|
||
ingame_nick = inter.text_values["ingame_nick"]
|
||
asvdv_nick = inter.text_values["asvdv_nick"]
|
||
echo_nick = inter.text_values["echo_nick"]
|
||
timezone = inter.text_values["timezone"]
|
||
|
||
embed = disnake.Embed(
|
||
title="📥 Новая анкета после собеседования",
|
||
color=disnake.Color.dark_purple(),
|
||
timestamp=datetime.datetime.now()
|
||
)
|
||
embed.set_author(name=inter.author.name, icon_url=inter.author.display_avatar.url)
|
||
embed.add_field(name="👤 Кто проводил собеседование", value=interviewer, inline=False)
|
||
embed.add_field(name="🎮 Ник в игре", value=ingame_nick, inline=True)
|
||
embed.add_field(name="🌐 Ник на AS VDV", value=asvdv_nick, inline=True)
|
||
embed.add_field(name="🌐 Ник на ECHO", value=echo_nick, inline=True)
|
||
embed.add_field(name="🕒 Часовой пояс", value=timezone, inline=True)
|
||
|
||
channel = bot.get_channel(TICKET_CHANNEL_ID)
|
||
if channel:
|
||
shtab_role = channel.guild.get_role(ROLE_SHTAB_ID)
|
||
keeper_role = channel.guild.get_role(KEEPER_ROLE_ID)
|
||
|
||
mention_text = ""
|
||
if shtab_role:
|
||
mention_text += shtab_role.mention + " "
|
||
if keeper_role:
|
||
mention_text += keeper_role.mention
|
||
|
||
view = ApproveRejectButtons(inter.author.id)
|
||
msg = await channel.send(content=mention_text, embed=embed, view=view)
|
||
|
||
# ✅ Сохраняем тикет
|
||
tickets[str(msg.id)] = {
|
||
"applicant_id": inter.author.id,
|
||
"resolved": False
|
||
}
|
||
save_tickets()
|
||
|
||
await inter.response.send_message("✅ Форма успешно отправлена!", ephemeral=True)
|
||
|
||
|
||
|
||
|
||
|
||
# ================================
|
||
# 🎛️ Кнопки под embed-сообщением
|
||
# ================================
|
||
class TicketButtons(disnake.ui.View):
|
||
def __init__(self):
|
||
super().__init__(timeout=None) # Кнопки вечные (до удаления вручную)
|
||
|
||
@disnake.ui.button(label="Игровая информация", style=disnake.ButtonStyle.primary, emoji="🔔", custom_id="create_ticket")
|
||
async def create_ticket_button(self, button: disnake.ui.Button, inter: disnake.MessageInteraction):
|
||
# ⬆️ При нажатии открывается форма тикета
|
||
await inter.response.send_modal(TicketModal())
|
||
|
||
|
||
# ================================
|
||
# 🛠️ Команда для создания Embed
|
||
# ================================
|
||
@bot.command()
|
||
@commands.has_permissions(administrator=True)
|
||
async def тикет_сообщение(ctx):
|
||
# 💬 Текст embed-сообщения
|
||
embed = disnake.Embed(
|
||
title="Игровая информация.",
|
||
description="Необходимо ответить на несколько вопросов.\n"
|
||
"Это нужно для оперативного принятия заявок на игровых проектов.\n"
|
||
"После чего ваша заявка будет отправлена на проверку. \n"
|
||
"Как только ваша заявка пройдёт проверку вам придёт уведомления в личные сообщения. \n"
|
||
"Убедитесь, что у вас открыта ЛС для общих серверов.",
|
||
color=disnake.Color.blue()
|
||
)
|
||
embed.set_footer(text=datetime.datetime.now().strftime("%d.%m.%Y %H:%M"))
|
||
|
||
# 📤 Отправка сообщения с кнопками
|
||
await ctx.send(embed=embed, view=TicketButtons())
|
||
|
||
|
||
|
||
|
||
@bot.event
|
||
async def on_ready():
|
||
print(f"✅ Бот запущен как {bot.user}")
|
||
|
||
# 📥 Регистрация кнопок
|
||
bot.add_view(TicketButtons())
|
||
|
||
# 🔄 Загрузка данных
|
||
load_user_data()
|
||
load_tickets()
|
||
asyncio.create_task(restore_ticket_buttons())
|
||
|
||
# 🎯 Получаем гильдию
|
||
guild = bot.get_guild(GUILD_ID)
|
||
if guild is None:
|
||
print(f"❌ Бот не состоит в гильдии с ID {GUILD_ID}")
|
||
else:
|
||
print(f"🔗 Подключён к серверу: {guild.name} (ID: {guild.id})")
|
||
|
||
# ✅ Передаём функции в scheduler до запуска цикла
|
||
import scheduler
|
||
scheduler.event_loop_context["create_poll"] = create_poll
|
||
scheduler.event_loop_context["event_tasks"] = event_tasks
|
||
scheduler.event_loop_context["generate_event_id"] = generate_event_id
|
||
scheduler.event_loop_context["POLL_CHANNEL_ID"] = POLL_CHANNEL_ID
|
||
|
||
# 🔁 Восстановление событий и напоминаний
|
||
now = datetime.datetime.now(MOSCOW_TZ)
|
||
for event_id, data in event_tasks.items():
|
||
if data["event"]["start_time"] > now:
|
||
await rebind_poll_buttons(event_id)
|
||
asyncio.create_task(schedule_reminders(event_id))
|
||
|
||
# ▶️ Запуск циклов (только после инициализации scheduler)
|
||
scheduler_enabled = load_scheduler_state()
|
||
scheduler.AUTO_EVENTS_ENABLED = scheduler_enabled
|
||
print(f"🕒 Автосоздание голосований (scheduler): {'✅ ВКЛ' if scheduler_enabled else '⛔ ВЫКЛ'}")
|
||
|
||
if scheduler_enabled:
|
||
if not scheduler.scheduled_event_loop.is_running():
|
||
scheduler.scheduled_event_loop.start()
|
||
|
||
if not activity_check_loop.is_running():
|
||
activity_check_loop.start()
|
||
|
||
|
||
|
||
# 🔄 Восстанавливаем висящие тикеты
|
||
async def restore_ticket_buttons():
|
||
guild = bot.get_guild(GUILD_ID)
|
||
ticket_channel = guild.get_channel(TICKET_CHANNEL_ID) if guild else None
|
||
if not ticket_channel:
|
||
print("⚠️ Канал для тикетов не найден.")
|
||
return
|
||
|
||
for msg_id, data in tickets.items():
|
||
if data.get("resolved"):
|
||
continue # Тикет уже обработан
|
||
|
||
try:
|
||
msg = await ticket_channel.fetch_message(int(msg_id))
|
||
except Exception as e:
|
||
logging.exception("Не удалось получить сообщение:")
|
||
print(f"⚠️ Не удалось получить сообщение {msg_id}: {e}")
|
||
continue
|
||
|
||
view = ApproveRejectButtons(data["applicant_id"])
|
||
try:
|
||
await msg.edit(view=view)
|
||
print(f"🔁 Восстановлены кнопки для тикета {msg_id}")
|
||
except Exception as e:
|
||
logging.exception("Не удалось обновить сообщение:")
|
||
print(f"⚠️ Не удалось обновить сообщение {msg_id}: {e}")
|
||
|
||
|
||
|
||
|
||
|
||
# ================================================
|
||
# 🟢 Запуск бота
|
||
# ================================================
|
||
|
||
with open("config.json", "r", encoding="utf-8") as f:
|
||
config = json.load(f)
|
||
|
||
TOKEN = config["token"]
|
||
bot.run(TOKEN)
|
||
|