## ================================================ # 📦 [БЛОК 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"**Начало:** \n" 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 = [] for event in scheduler.SCHEDULED_EVENTS: create_time = scheduler.get_next_datetime(now, event["day"], event["create_hour"], event["create_minute"]) start_time = scheduler.get_next_datetime(now, event["day"], event["event_hour"], event["event_minute"]) event_id = scheduler.event_loop_context["generate_event_id"](event["name"], start_time) if event_id in scheduler.event_loop_context["event_tasks"]: continue info = { "name": event["name"], "start_time": start_time, } await scheduler.event_loop_context["create_poll"](info, message_id=event["key"]) created.append(info["name"]) if created: await ctx.send(f"✅ Созданы события: {', '.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 from scheduler import SCHEDULED_EVENTS, get_next_datetime # 💾 сохраняем состояние save_scheduler_state(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"• Локально: \n\n" f"🎯 **Начало события:**\n" f"• По МСК: `{start_time.strftime('%d.%m.%Y %H:%M')}`\n" 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 # 💾 сохраняем состояние save_scheduler_state(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"• Локально: \n\n" f"🎯 **Начало события:**\n" f"• По МСК: `{start_time.strftime('%d.%m.%Y %H:%M')}`\n" 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="`!ивент_отчёт `", value="Показать отчёт по участникам события (для Штаба).", inline=False) embed.add_field(name="`!ивент_удалить `", 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 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 для опроса: показывает **никнеймы голосующих** вместо процентов. 🕒 Время по МСК и по локальному времени сохраняется. """ # 📅 Время начала события 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"🌍 По вашему времени: \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"🌍 По вашему времени: \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"**Ваше местное время:** \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) # 🧹 Удаление события через 13 часов после начала async def delete_expired_event(): now = datetime.datetime.now(MOSCOW_TZ) await asyncio.sleep((start_time - datetime.datetime.now(MOSCOW_TZ)).total_seconds() + 4 * 3600) # Удаление сообщения try: poll_msg_id = event_tasks[event_id].get("poll_msg_id") channel = bot.get_channel(POLL_CHANNEL_ID) 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}' автоматически удалено через 13ч после начала.") # 🧠 Планируем все задачи 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()) # Через 4 часов удаление 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}`" # 🕒 — локальное время для пользователя time_line = 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() 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)