Files
VLNKI_Bot/main.py
2026-05-23 18:36:21 +00:00

2298 lines
91 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## ================================================
# 📦 [БЛОК 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()) # Через 7 дней удаление
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)