From 349815a2aaa07db0b8bf5180cd0aeba064f87a44 Mon Sep 17 00:00:00 2001 From: mtorov Date: Tue, 29 Sep 2026 13:10:37 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20bot.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 1763 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 1298 insertions(+), 465 deletions(-) diff --git a/bot.py b/bot.py index e8073f6..748ea03 100644 --- a/bot.py +++ b/bot.py @@ -1,465 +1,1298 @@ -import asyncio -import logging -import time -import gspread -from google.oauth2.service_account import Credentials -from aiogram import Bot, Dispatcher, types -from aiogram.filters import Command - -# ============================================================================== -# 1. НАСТРОЙКА ЛОГИРОВАНИЯ (Обязательно в самом верху файла!) -# ============================================================================== -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - -# ============================================================================== -# --- НАСТРОЙКИ --- -# ВСТАВЬТЕ СЮДА НОВЫЙ ТОКЕН ПОСЛЕ /revoke В BOTFATHER! -BOT_TOKEN = "8962252858:AAEkZSgyWKaRHB_wlISvhcRVsbV91F32oi0" -# Вставьте ID вашей Google Таблицы (длинная строка из адресной строки между /d/ и /edit) -SPREADSHEET_ID = "1JU8RuJNGgDetNpSaAoUhbUwAiuPDNOSdFG2SfaWjmTI" - -# ВАЖНО: Все ключи должны быть в нижнем регистре для корректного поиска! -GROUP_ALIASES = { - # Космос - "космос": "Космос", "космоса": "Космос", "космосу": "Космос", "космосом": "Космос", "космосе": "Космос", - "группа космос": "Космос", "группы космос": "Космос", "группа космоса": "Космос", "группы космоса": "Космос", - - # Юго-запад - "юз": "Юго-запад", - "юго запад": "Юго-запад", "юго-запад": "Юго-запад", - "юго запада": "Юго-запад", "юго-запада": "Юго-запад", - "юго западу": "Юго-запад", "юго-западу": "Юго-запад", - "юго западом": "Юго-запад", "юго-западом": "Юго-запад", - "юго западе": "Юго-запад", "юго-западе": "Юго-запад", - "группа юз": "Юго-запад", "группы юз": "Юго-запад", - "группа юго запад": "Юго-запад", "группы юго запад": "Юго-запад", - "группа юго-запад": "Юго-запад", "группы юго-запад": "Юго-запад", - "группа юго запада": "Юго-запад", "группы юго запада": "Юго-запад", - "группа юго-запада": "Юго-запад", "группы юго-запада": "Юго-запад", - - # Семья - "семья": "Семья", "семьи": "Семья", "семье": "Семья", "семью": "Семья", "семьей": "Семья", "семьёй": "Семья", - "группа семья": "Семья", "группы семья": "Семья", "группа семьи": "Семья", "группы семьи": "Семья", - "группа семье": "Семья", "группы семье": "Семья", "группа семью": "Семья", "группы семью": "Семья", - - # Старый Арбат - "арбат": "Старый Арбат", "арбата": "Старый Арбат", "арбату": "Старый Арбат", "арбатом": "Старый Арбат", "арбате": "Старый Арбат", - "старый арбат": "Старый Арбат", "старого арбата": "Старый Арбат", "старому арбату": "Старый Арбат", - "старым арбатом": "Старый Арбат", "старом арбате": "Старый Арбат", - "группа арбат": "Старый Арбат", "группы арбат": "Старый Арбат", "группа арбата": "Старый Арбат", "группы арбата": "Старый Арбат", - "группа старый арбат": "Старый Арбат", "группы старый арбат": "Старый Арбат", - "группа старого арбата": "Старый Арбат", "группы старого арбата": "Старый Арбат", - - # Надежда - "надежда": "Надежда", "надежды": "Надежда", "надежде": "Надежда", "надежду": "Надежда", "надеждой": "Надежда", - "движение надежда": "Надежда", "движение надежды": "Надежда", "движения надежда": "Надежда", "движения надежды": "Надежда", - "группа надежда": "Надежда", "группы надежда": "Надежда", "группа надежды": "Надежда", "группы надежды": "Надежда", - "группа надежде": "Надежда", "группы надежде": "Надежда", "группа надежду": "Надежда", "группы надежду": "Надежда", - - # Сказка - "сказка": "Сказка", "сказки": "Сказка", "сказке": "Сказка", "сказку": "Сказка", "сказкой": "Сказка", - "группа сказка": "Сказка", "группы сказка": "Сказка", "группа сказки": "Сказка", "группы сказки": "Сказка", - "группа сказке": "Сказка", "группы сказке": "Сказка", "группа сказку": "Сказка", "группы сказку": "Сказка", - - # Московский проспект - "мп": "Московский проспект", - "московский проспект": "Московский проспект", "московского проспекта": "Московский проспект", - "московскому проспекту": "Московский проспект", "московским проспектом": "Московский проспект", - "московском проспекте": "Московский проспект", - "группа мп": "Московский проспект", "группы мп": "Московский проспект", - "группа московский проспект": "Московский проспект", "группы московский проспект": "Московский проспект", - "группа московского проспекта": "Московский проспект", "группы московского проспекта": "Московский проспект", - - # ЛитКом - "литком": "ЛитКом", "литкома": "ЛитКом", "литкому": "ЛитКом", "литкомом": "ЛитКом", "литкоме": "ЛитКом", - "группа литком": "ЛитКом", "группы литком": "ЛитКом", "группа литкома": "ЛитКом", "группы литкома": "ЛитКом", - - # ПСО - "псо": "ПСО", - "группа псо": "ПСО", "группы псо": "ПСО", - - # БУ - "бу": "БУ", - "группа бу": "БУ", "группы бу": "БУ", - - # ПМ - "пм": "ПМ", - "группа пм": "ПМ", "группы пм": "ПМ", - - # РС - "рс": "РС", - "группа рс": "РС", "группы рс": "РС", - - # Движение (Нововоронеж) - "движение": "Движение", "движения": "Движение", "движению": "Движение", "движением": "Движение", "движении": "Движение", - "нововоронеж": "Движение", "нововоронежа": "Движение", "нововоронежу": "Движение", "нововоронежем": "Движение", "нововоронеже": "Движение", - "движение нововоронеж": "Движение", "движение нововоронежа": "Движение", - "движения нововоронеж": "Движение", "движения нововоронежа": "Движение", - "группа движение": "Движение", "группы движение": "Движение", - "группа движения": "Движение", "группы движения": "Движение", - "группа нововоронеж": "Движение", "группы нововоронеж": "Движение", - "группа нововоронежа": "Движение", "группы нововоронежа": "Движение", - - # Только сегодня Калач - "калач": "Только сегодня Калач", "калача": "Только сегодня Калач", "калачу": "Только сегодня Калач", - "калачом": "Только сегодня Калач", "калаче": "Только сегодня Калач", - "только сегодня": "Только сегодня Калач", - "только сегодня калач": "Только сегодня Калач", "только сегодня калача": "Только сегодня Калач", - "только сегодня калачу": "Только сегодня Калач", "только сегодня калачом": "Только сегодня Калач", - "группа калач": "Только сегодня Калач", "группы калач": "Только сегодня Калач", - "группа калача": "Только сегодня Калач", "группы калача": "Только сегодня Калач", - "группа только сегодня": "Только сегодня Калач", "группы только сегодня": "Только сегодня Калач", - "группа только сегодня калач": "Только сегодня Калач", "группы только сегодня калач": "Только сегодня Калач", - - # Виктория Лиски - "лиски": "Виктория Лиски", "лисок": "Виктория Лиски", "лискам": "Виктория Лиски", "лисками": "Виктория Лиски", "лисках": "Виктория Лиски", - "виктория": "Виктория Лиски", "виктории": "Виктория Лиски", - "викторию": "Виктория Лиски", "викторией": "Виктория Лиски", - "виктория лиски": "Виктория Лиски", "виктории лиски": "Виктория Лиски", "викторию лиски": "Виктория Лиски", - "группа виктория": "Виктория Лиски", "группы виктория": "Виктория Лиски", - "группа виктории": "Виктория Лиски", "группы виктории": "Виктория Лиски", - "группа викторию": "Виктория Лиски", "группы викторию": "Виктория Лиски", - "группа лиски": "Виктория Лиски", "группы лиски": "Виктория Лиски", - "группа виктория лиски": "Виктория Лиски", "группы виктория лиски": "Виктория Лиски", - - # Щепка Курск - "щепка": "Щепка Курск", "щепки": "Щепка Курск", "щепке": "Щепка Курск", "щепку": "Щепка Курск", "щепкой": "Щепка Курск", - "щепка курск": "Щепка Курск", "щепки курск": "Щепка Курск", "щепка курска": "Щепка Курск", "щепки курска": "Щепка Курск", - "группа щепка": "Щепка Курск", "группы щепка": "Щепка Курск", "группа щепки": "Щепка Курск", "группы щепки": "Щепка Курск", - "группа щепка курск": "Щепка Курск", "группы щепка курск": "Щепка Курск", - - # Кузня Курск - "кузня": "Кузня Курск", "кузни": "Кузня Курск", "кузне": "Кузня Курск", "кузню": "Кузня Курск", "кузней": "Кузня Курск", "кузнёй": "Кузня Курск", - "кузня курск": "Кузня Курск", "кузни курск": "Кузня Курск", "кузня курска": "Кузня Курск", "кузни курска": "Кузня Курск", - "группа кузня": "Кузня Курск", "группы кузня": "Кузня Курск", "группа кузни": "Кузня Курск", "группы кузни": "Кузня Курск", - "группа кузня курск": "Кузня Курск", "группы кузня курск": "Кузня Курск", - - # Высота Белгород - "высота": "Высота Белгород", "высоты": "Высота Белгород", "высоте": "Высота Белгород", "высоту": "Высота Белгород", "высотой": "Высота Белгород", - "высота белгород": "Высота Белгород", "высоты белгород": "Высота Белгород", - "высота белгорода": "Высота Белгород", "высоты белгорода": "Высота Белгород", - "группа высота": "Высота Белгород", "группы высота": "Высота Белгород", - "группа высоты": "Высота Белгород", "группы высоты": "Высота Белгород", - "группа высота белгород": "Высота Белгород", "группы высота белгород": "Высота Белгород", - - # Наркоз Липецк - "наркоз": "Наркоз Липецк", "наркоза": "Наркоз Липецк", "наркозу": "Наркоз Липецк", "наркозом": "Наркоз Липецк", "наркозе": "Наркоз Липецк", - "липецк": "Наркоз Липецк", "липецка": "Наркоз Липецк", "липецку": "Наркоз Липецк", "липецком": "Наркоз Липецк", "липецке": "Наркоз Липецк", - "наркоз липецк": "Наркоз Липецк", "наркоза липецк": "Наркоз Липецк", "наркоз липецка": "Наркоз Липецк", - "группа наркоз": "Наркоз Липецк", "группы наркоз": "Наркоз Липецк", - "группа наркоза": "Наркоз Липецк", "группы наркоза": "Наркоз Липецк", - "группа липецк": "Наркоз Липецк", "группы липецк": "Наркоз Липецк", - "группа наркоз липецк": "Наркоз Липецк", "группы наркоз липецк": "Наркоз Липецк", - - # Волна Липецк - "волна": "Волна Липецк", "волны": "Волна Липецк", "волне": "Волна Липецк", "волну": "Волна Липецк", "волной": "Волна Липецк", - "волна липецк": "Волна Липецк", "волны липецк": "Волна Липецк", "волна липецка": "Волна Липецк", "волны липецка": "Волна Липецк", - "группа волна": "Волна Липецк", "группы волна": "Волна Липецк", - "группа волны": "Волна Липецк", "группы волны": "Волна Липецк", - "группа волна липецк": "Волна Липецк", "группы волна липецк": "Волна Липецк", - - # Утренняя звезда Тамбов - "тамбов": "Утренняя звезда Тамбов", "тамбова": "Утренняя звезда Тамбов", "тамбову": "Утренняя звезда Тамбов", - "тамбовом": "Утренняя звезда Тамбов", "тамбове": "Утренняя звезда Тамбов", - "утренняя звезда": "Утренняя звезда Тамбов", "утренней звезды": "Утренняя звезда Тамбов", - "утренней звезде": "Утренняя звезда Тамбов", "утреннюю звезду": "Утренняя звезда Тамбов", - "утренней звездой": "Утренняя звезда Тамбов", - "утренняя звезда тамбов": "Утренняя звезда Тамбов", "утренней звезды тамбов": "Утренняя звезда Тамбов", - "группа тамбов": "Утренняя звезда Тамбов", "группы тамбов": "Утренняя звезда Тамбов", - "группа утренняя звезда": "Утренняя звезда Тамбов", "группы утренняя звезда": "Утренняя звезда Тамбов", - "группа утренней звезды": "Утренняя звезда Тамбов", "группы утренней звезды": "Утренняя звезда Тамбов", - - # Детектор Старый Оскол - "детектор": "Детектор Старый Оскол", "детектора": "Детектор Старый Оскол", "детектору": "Детектор Старый Оскол", - "детектором": "Детектор Старый Оскол", "детекторе": "Детектор Старый Оскол", - "старый оскол": "Детектор Старый Оскол", "старого оскола": "Детектор Старый Оскол", - "старому осколу": "Детектор Старый Оскол", "старым осколом": "Детектор Старый Оскол", - "старом осколе": "Детектор Старый Оскол", - "детектор старый оскол": "Детектор Старый Оскол", "детектора старый оскол": "Детектор Старый Оскол", - "группа детектор": "Детектор Старый Оскол", "группы детектор": "Детектор Старый Оскол", - "группа детектора": "Детектор Старый Оскол", "группы детектора": "Детектор Старый Оскол", - "группа старый оскол": "Детектор Старый Оскол", "группы старый оскол": "Детектор Старый Оскол", - - # Среда Белгород - "среда": "Среда Белгород", "среды": "Среда Белгород", "среде": "Среда Белгород", "среду": "Среда Белгород", "средой": "Среда Белгород", - "среда белгород": "Среда Белгород", "среды белгород": "Среда Белгород", - "среда белгорода": "Среда Белгород", "среды белгорода": "Среда Белгород", - "группа среда": "Среда Белгород", "группы среда": "Среда Белгород", - "группа среды": "Среда Белгород", "группы среды": "Среда Белгород", - "группа среда белгород": "Среда Белгород", "группы среда белгород": "Среда Белгород", -} - -# ============================================================================== -# 3. ПОДКЛЮЧЕНИЕ К GOOGLE ТАБЛИЦЕ -# ============================================================================== -SCOPES = ['https://www.googleapis.com/auth/spreadsheets'] -try: - creds = Credentials.from_service_account_file('credentials.json', scopes=SCOPES) - gc = gspread.authorize(creds) - sheet = gc.open_by_key(SPREADSHEET_ID).sheet1 - logging.info("✅ Google Таблица успешно подключена!") -except FileNotFoundError: - logging.error("❌ Файл 'credentials.json' не найден. Положите его в папку с ботом.") - exit(1) -except Exception as e: - logging.error(f"❌ Ошибка подключения к Google Таблице: {e}") - exit(1) - -# ============================================================================== -# 4. НАСТРОЙКА БОТА -# ============================================================================== -bot = Bot(token=BOT_TOKEN) -dp = Dispatcher() - -# ============================================================================== -# 5. ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ (Синхронные, будут вызываться через asyncio.to_thread) -# ============================================================================== - -def get_col_index(sheet_obj, target_name): - """Находит номер колонки по названию группы (игнорирует переносы строк и регистр)""" - headers = sheet_obj.row_values(1) - target_clean = target_name.lower().replace('\n', ' ').replace(' ', ' ').strip() - for i, h in enumerate(headers): - h_clean = h.lower().replace('\n', ' ').replace(' ', ' ').strip() - if target_clean == h_clean: - return i + 1 # gspread использует индексацию с 1 - return None - -def get_message_text(message: types.Message) -> str: - """Безопасно извлекает текст: либо из сообщения, либо из подписи (caption) к фото/медиа.""" - return message.text or message.caption or "" - -def parse_flag_event(text: str): - """Анализирует текст. Первая найденная группа = забирает, вторая = отдает.""" - text_lower = text.lower() - if 'флаг' not in text_lower: - return None, None, False - - is_own = 'свой' in text_lower - found_groups = [] - # Сортируем по длине ключа, чтобы "юго-запад" матчился раньше, чем "юз" - sorted_aliases = sorted(GROUP_ALIASES.keys(), key=len, reverse=True) - - for alias in sorted_aliases: - if alias in text_lower: - canonical_name = GROUP_ALIASES[alias] - if canonical_name not in found_groups: - found_groups.append(canonical_name) - - if len(found_groups) >= 2: - return found_groups[0], found_groups[1], is_own - - return None, None, False - -def update_horizontal_sheet(sheet_obj, winner: str, loser: str, is_own: bool): - """Обновляет данные, минимизируя количество запросов к API (Batch Read)""" - col_winner = get_col_index(sheet_obj, winner) - col_loser = get_col_index(sheet_obj, loser) - - if not col_winner or not col_loser: - raise ValueError(f"Не найдены колонки для '{winner}' или '{loser}' в таблице") - - # Читаем 4-ю и 5-ю строки ОДНИМ запросом для получения актуальных данных - data = sheet_obj.get('4:5') - row_4 = data[0] if len(data) > 0 else [] - row_5 = data[1] if len(data) > 1 else [] - - idx_winner = col_winner - 1 - idx_loser = col_loser - 1 - - if is_own: - sheet_obj.update_cell(4, col_winner, "на месте") - cell_val = row_5[idx_loser] if idx_loser < len(row_5) else "" - captured_list = [x.strip() for x in str(cell_val).replace('\n', ',').split(',') if x.strip()] - if winner in captured_list: - captured_list.remove(winner) - sheet_obj.update_cell(5, col_loser, ", ".join(captured_list)) - else: - sheet_obj.update_cell(4, col_loser, f"у {winner}") - cell_val = row_5[idx_winner] if idx_winner < len(row_5) else "" - captured_list = [x.strip() for x in str(cell_val).replace('\n', ',').split(',') if x.strip()] - if loser not in captured_list: - captured_list.append(loser) - sheet_obj.update_cell(5, col_winner, ", ".join(captured_list)) - - # Сбрасываем кэш сводки после любого изменения, чтобы следующий запрос показал свежие данные - global _summary_cache - _summary_cache["timestamp"] = 0 - - -# ============================================================================== -# 6. КЭШИРОВАНИЕ И ГЕНЕРАЦИЯ СВОДКИ (Оптимизация против ошибки 429) -# ============================================================================== -_summary_cache = {"data": None, "timestamp": 0} -CACHE_TTL_SECONDS = 30 # Храним кэш 30 секунд для защиты от спама командой /флаги - -def generate_summary(sheet_obj) -> str: - """Формирует текстовую сводку, делая ВСЕГО ОДИН запрос к API Google""" - try: - # Читаем строки с 1 по 5 включительно ЗА ОДИН запрос! - data = sheet_obj.get('1:5') - except Exception as e: - raise e - - if not data or len(data) < 5: - return "📊 Таблица пуста или не заполнена." - - headers = data[0] # 1-я строка (заголовки) - row_4 = data[3] # 4-я строка (статус своего флага) - row_5 = data[4] # 5-я строка (захваченные флаги) - - summary_lines = [] - - # Начинаем с индекса 1 (вторая колонка), так как 0-я это "Группы и комитеты" - for col in range(1, len(headers)): - group = headers[col] - if not group or not str(group).strip(): - continue - - own_flag_status = row_4[col] if col < len(row_4) else "" - captured = row_5[col] if col < len(row_5) else "" - - flags_text = [] - - if own_flag_status: - clean_status = str(own_flag_status).strip() - if clean_status.lower() == "на месте": - flags_text.append("свой флаг на месте") - else: - flags_text.append(f"свой флаг {clean_status}") - else: - flags_text.append("статус своего флага не указан") - - if captured: - captured_list = [x.strip() for x in str(captured).replace('\n', ',').split(',') if x.strip()] - for cap in captured_list: - flags_text.append(f"захвачен флаг: {cap}") - - summary_lines.append(f"🚩 **{group}**: {', '.join(flags_text)}") - - return "\n".join(summary_lines) if summary_lines else "📊 Таблица пуста или не заполнена." - -def get_cached_summary(sheet_obj) -> str: - """Возвращает кэшированную сводку или генерирует новую, если кэш устарел""" - current_time = time.time() - if _summary_cache["data"] and (current_time - _summary_cache["timestamp"]) < CACHE_TTL_SECONDS: - return _summary_cache["data"] - - new_summary = generate_summary(sheet_obj) - _summary_cache["data"] = new_summary - _summary_cache["timestamp"] = current_time - return new_summary - - -# ============================================================================== -# 7. ФИЛЬТРЫ СООБЩЕНИЙ -# ============================================================================== - -def is_summary_filter(message: types.Message) -> bool: - """Проверяет, является ли сообщение запросом сводки""" - text = get_message_text(message).lower().strip() - if not text: - return False - - clean_text = text.replace('@googleanvrn_bot', '').strip() - clean_text = clean_text.lstrip('/').strip() - - return clean_text in ['флаги', 'summary'] - -def is_flag_event_filter(message: types.Message) -> bool: - """Проверяет, является ли сообщение событием захвата флага""" - text = get_message_text(message) - if not text: - return False - - text_lower = text.lower().strip() - - # ВАЖНО: Сначала исключаем запросы сводки, чтобы слово "флаги" не триггерило этот фильтр - if is_summary_filter(message): - return False - - return bool('@googleanvrn_bot' in text_lower and 'флаг' in text_lower) - - -# ============================================================================== -# 8. ОБРАБОТЧИКИ СООБЩЕНИЙ (Асинхронные) -# ============================================================================== - -@dp.message(Command("start")) -async def cmd_start(message: types.Message): - await message.answer( - "🤖 Бот для учета флагов запущен!\n\n" - "Вы можете отправить обычное сообщение или фото с подписью:\n" - "@googleAnVrn_bot Космос забрал свой флаг у ЮЗ\n" - "@googleAnVrn_bot Литком забрали флаг у РС\n\n" - "Для получения сводки используйте: /флаги или @googleAnVrn_bot флаги" - ) - -@dp.message(is_summary_filter) -async def cmd_summary(message: types.Message): - await message.answer("⏳ Собираю данные из таблицы...") - try: - # Используем кэшированную версию функции в отдельном потоке - summary = await asyncio.to_thread(get_cached_summary, sheet) - await message.answer(summary, parse_mode="Markdown") - - except gspread.exceptions.APIError as api_err: - # Специальная обработка ошибки 429 (Quota Exceeded) - if "429" in str(api_err): - logging.warning("⚠️ Превышен лимит запросов к Google Sheets (429).") - await message.answer( - "⚠️ Слишком много запросов к таблице прямо сейчас. " - "Google временно ограничил чтение. Пожалуйста, подождите 1 минуту и попробуйте снова." - ) - else: - logging.error(f"Ошибка Google API: {api_err}", exc_info=True) - await message.answer("❌ Произошла ошибка при обращении к Google Таблице.") - - except Exception as e: - logging.error(f"Критическая ошибка генерации сводки: {e}", exc_info=True) - await message.answer("❌ Не удалось прочитать таблицу.") - - -@dp.message(is_flag_event_filter) -async def handle_flag_event(message: types.Message): - text_to_parse = get_message_text(message) - winner, loser, is_own = parse_flag_event(text_to_parse) - - if not winner or not loser: - await message.reply( - "⚠️ Не удалось распознать две группы в сообщении или подписи к фото. " - "Используйте понятные названия (например: Космос, ЮЗ, ЛитКом, РС, Надежда, Сказка, Семья, МП, Щепка, Кузня, Высота, Только сегодня, Виктория)." - ) - return - - try: - # Оборачиваем синхронную работу с gspread в asyncio.to_thread - await asyncio.to_thread(update_horizontal_sheet, sheet, winner, loser, is_own) - - if is_own: - text_response = f"✅ Зафиксировано: **{winner}** вернул(а) свой флаг (теперь он на месте)." - else: - text_response = f"✅ Зафиксировано: **{winner}** забрал(а) флаг у **{loser}**." - - await message.reply(text_response, parse_mode="Markdown") - - except ValueError as ve: - logging.warning(f"Ошибка валидации данных: {ve}") - await message.reply(f"❌ Ошибка: {ve}. Проверьте заголовки в таблице.") - except Exception as e: - logging.error(f"Критическая ошибка записи в таблицу: {e}", exc_info=True) - await message.reply("❌ Произошла непредвиденная ошибка при записи в таблицу.") - - -# ============================================================================== -# 9. ЗАПУСК -# ============================================================================== -async def main(): - logging.info("🚀 Бот запущен и начинает слушать сообщения...") - await dp.start_polling(bot) - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - logging.info("🛑 Бот остановлен пользователем.") \ No newline at end of file +import asyncio +import logging +import re +import time +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import gspread +from google.auth.transport.requests import AuthorizedSession +from google.oauth2.service_account import Credentials +from aiogram import Bot, Dispatcher, F, types +from aiogram.filters import Command +from gspread.utils import rowcol_to_a1 + + +# ============================================================================== +# 1. ЛОГИРОВАНИЕ +# ============================================================================== + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + + +# ============================================================================== +# 2. НАСТРОЙКИ +# ============================================================================== + +BOT_TOKEN = "8962252858:AAEkZSgyWKaRHB_wlISvhcRVsbV91F32oi0" +SPREADSHEET_ID = "1JU8RuJNGgDetNpSaAoUhbUwAiuPDNOSdFG2SfaWjmTI" +BOT_MENTION = "@googleanvrn_bot" + +BASE_DIR = Path(__file__).resolve().parent +CREDENTIALS_FILE = BASE_DIR / "credentials.json" + +SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.metadata.readonly", +] + +CACHE_TTL_SECONDS = 30 + + +# ============================================================================== +# 3. ГРУППЫ И АЛИАСЫ +# ============================================================================== + +# Каноническое название -> варианты, которыми его могут написать. +# +# Составные aliases вида "движение надежда" намеренно не используются: +# они могут поглотить ДВЕ отдельные группы как одну. + +GROUP_ALIASES = { + "Космос": [ + "космос", "космоса", "космосу", "космосом", "космосе", + "группа космос", "группы космос", + "группа космоса", "группы космоса", + ], + + "Юго-запад": [ + "юз", + "юго запад", "юго-запад", + "юго запада", "юго-запада", + "юго западу", "юго-западу", + "юго западом", "юго-западом", + "юго западе", "юго-западе", + "группа юз", "группы юз", + "группа юго запад", "группы юго запад", + "группа юго-запад", "группы юго-запад", + "группа юго запада", "группы юго запада", + "группа юго-запада", "группы юго-запада", + ], + + "Семья": [ + "семья", "семьи", "семье", "семью", "семьей", "семьёй", + "группа семья", "группы семья", + "группа семьи", "группы семьи", + "группа семье", "группы семье", + "группа семью", "группы семью", + ], + + "Старый Арбат": [ + "арбат", "арбата", "арбатy", "арбатом", "арбате", + "старый арбат", "старого арбата", + "старому арбату", "старым арбатом", "старом арбате", + "группа арбат", "группы арбат", + "группа арбата", "группы арбата", + "группа старый арбат", "группы старый арбат", + "группа старого арбата", "группы старого арбата", + ], + + "Надежда": [ + "надежда", "надежды", "надежде", "надежду", "надеждой", + "группа надежда", "группы надежда", + "группа надежды", "группы надежды", + "группа надежде", "группы надежде", + "группа надежду", "группы надежду", + ], + + "Сказка": [ + "сказка", "сказки", "сказке", "сказку", "сказкой", + "группа сказка", "группы сказка", + "группа сказки", "группы сказки", + "группа сказке", "группы сказке", + "группа сказку", "группы сказку", + ], + + "Московский проспект": [ + "мп", + "московский проспект", "московского проспекта", + "московскому проспекту", "московским проспектом", + "московском проспекте", + "группа мп", "группы мп", + "группа московский проспект", "группы московский проспект", + "группа московского проспекта", "группы московского проспекта", + ], + + "ЛитКом": [ + "литком", "литкома", "литкому", "литкомом", "литкоме", + "группа литком", "группы литком", + "группа литкома", "группы литкома", + ], + + "ПСО": [ + "псо", + "группа псо", "группы псо", + ], + + "БУ": [ + "бу", + "группа бу", "группы бу", + ], + + "ПМ": [ + "пм", + "группа пм", "группы пм", + ], + + "РС": [ + "рс", + "группа рс", "группы рс", + ], + + "Движение": [ + "движение", "движения", "движению", "движением", "движении", + "нововоронеж", "нововоронежа", "нововоронежу", + "нововоронежем", "нововоронеже", + "группа движение", "группы движение", + "группа движения", "группы движения", + "группа нововоронеж", "группы нововоронеж", + "группа нововоронежа", "группы нововоронежа", + ], + + "Только сегодня Калач": [ + "калач", "калача", "калачу", "калачом", "калаче", + "только сегодня", + "только сегодня калач", "только сегодня калача", + "только сегодня калачу", "только сегодня калачом", + "группа калач", "группы калач", + "группа калача", "группы калача", + "группа только сегодня", "группы только сегодня", + "группа только сегодня калач", "группы только сегодня калач", + ], + + "Виктория Лиски": [ + "лиски", "лисок", "лискам", "лисками", "лисках", + "виктория", "виктории", "викторию", "викторией", + "виктория лиски", "виктории лиски", "викторию лиски", + "группа виктория", "группы виктория", + "группа виктории", "группы виктории", + "группа викторию", "группы викторию", + "группа лиски", "группы лиски", + "группа виктория лиски", "группы виктория лиски", + ], + + "Щепка Курск": [ + "щепка", "щепки", "щепке", "щепку", "щепкой", + "щепка курск", "щепки курск", + "щепка курска", "щепки курска", + "группа щепка", "группы щепка", + "группа щепки", "группы щепки", + "группа щепка курск", "группы щепка курск", + ], + + "Кузня Курск": [ + "кузня", "кузни", "кузне", "кузню", "кузней", "кузнёй", + "кузня курск", "кузни курск", + "кузня курска", "кузни курска", + "группа кузня", "группы кузня", + "группа кузни", "группы кузни", + "группа кузня курск", "группы кузня курск", + ], + + "Высота Белгород": [ + "высота", "высоты", "высоте", "высоту", "высотой", + "высота белгород", "высоты белгород", + "высота белгорода", "высоты белгорода", + "группа высота", "группы высота", + "группа высоты", "группы высоты", + "группа высота белгород", "группы высота белгород", + ], + + "Наркоз Липецк": [ + "наркоз", "наркоза", "наркозу", "наркозом", "наркозе", + "липецк", "липецка", "липецку", "липецком", "липецке", + "наркоз липецк", "наркоза липецк", "наркоз липецка", + "группа наркоз", "группы наркоз", + "группа наркоза", "группы наркоза", + "группа липецк", "группы липецк", + "группа наркоз липецк", "группы наркоз липецк", + ], + + "Волна Липецк": [ + "волна", "волны", "волне", "волну", "волной", + "волна липецк", "волны липецк", + "волна липецка", "волны липецка", + "группа волна", "группы волна", + "группа волны", "группы волны", + "группа волна липецк", "группы волна липецк", + ], + + "Утренняя звезда Тамбов": [ + "тамбов", "тамбова", "тамбову", "тамбовом", "тамбове", + "утренняя звезда", "утренней звезды", + "утренней звезде", "утреннюю звезду", + "утренней звездой", + "утренняя звезда тамбов", + "утренней звезды тамбов", + "группа тамбов", "группы тамбов", + "группа утренняя звезда", "группы утренняя звезда", + "группа утренней звезды", "группы утренней звезды", + ], + + "Детектор Старый Оскол": [ + "детектор", "детектора", "детектору", "детектором", "детекторе", + "старый оскол", "старого оскола", "старому осколу", + "старым осколом", "старом осколе", + "детектор старый оскол", "детектора старый оскол", + "группа детектор", "группы детектор", + "группа детектора", "группы детектора", + "группа старый оскол", "группы старый оскол", + ], + + "Среда Белгород": [ + "среда", "среды", "среде", "среду", "средой", + "среда белгород", "среды белгород", + "среда белгорода", "среды белгорода", + "группа среда", "группы среда", + "группа среды", "группы среды", + "группа среда белгород", "группы среда белгород", + ], + + "Девочки": [ + "девочки", + "девочек", + "девочкам", + "девочками", + "девочках", + "группа девочки", + "группы девочки", + "группа девочек", + "группы девочек", + "группа девочкам", + "группы девочкам", + "группа девочками", + "группы девочками", + "группа девочках", + "группы девочках", + ], +} + + +# ============================================================================== +# 4. НОРМАЛИЗАЦИЯ И АЛИАСЫ +# ============================================================================== + +def norm(text: str) -> str: + text = str(text or "").lower().replace("ё", "е") + text = re.sub(r"[^а-яa-z0-9@_-]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +ALIAS_TO_CANONICAL = {} + +for canonical, aliases in GROUP_ALIASES.items(): + ALIAS_TO_CANONICAL[norm(canonical)] = canonical + + for alias in aliases: + ALIAS_TO_CANONICAL[norm(alias)] = canonical + + +ALIASES = sorted( + ( + (alias, canonical) + for canonical, aliases in GROUP_ALIASES.items() + for alias in aliases + ), + key=lambda item: len(norm(item[0])), + reverse=True, +) + + +def canonicalize_flag_name(value: str) -> str | None: + """Возвращает canonical-группу для значения из row 5 или None.""" + value_norm = norm(value) + + if not value_norm: + return None + + return ALIAS_TO_CANONICAL.get(value_norm) + + +def _alias_pattern(alias: str) -> re.Pattern: + """Компилирует безопасный поиск alias по границам слов.""" + return re.compile( + rf"(? list[str]: + """ + Находит все группы в порядке их фактического появления в сообщении. + + Пересекающиеся aliases схлопываются в одно совпадение, + при этом предпочтение получает более длинный alias. + + Отдельные повторные упоминания одной и той же группы НЕ удаляются: + это позволяет корректно обнаруживать неоднозначные сообщения + вида "Космос ... Космос ... ЮЗ". + """ + text_norm = norm(text) + + if not text_norm: + return [] + + matches = [] + + for alias, canonical, pattern in COMPILED_ALIASES: + for match in pattern.finditer(text_norm): + matches.append( + { + "start": match.start(), + "end": match.end(), + "length": match.end() - match.start(), + "canonical": canonical, + } + ) + + matches.sort( + key=lambda item: (-item["length"], item["start"]) + ) + + selected = [] + + for candidate in matches: + if any( + candidate["start"] < existing["end"] + and candidate["end"] > existing["start"] + for existing in selected + ): + continue + + selected.append(candidate) + + selected.sort( + key=lambda item: item["start"] + ) + + return [ + item["canonical"] + for item in selected + ] + + +def parse_flag_event(text: str) -> dict | None: + """Разбирает сообщение в событие изменения флага.""" + s = norm(text) + + if "флаг" not in s: + return None + + groups = find_groups(s) + + if not groups: + return None + + # Возврат собственного флага. + # Достаточно указать одну группу: + # "Космос вернул свой флаг". + if re.search(r"\bсвой\s+флаг(?:а|и)?\b", s): + return { + "kind": "own_return", + "owner": groups[0], + } + + # Обычное событие: ровно две разные группы. + if len(groups) != 2: + return None + + winner, owner = groups + + if winner == owner: + return None + + return { + "kind": "capture", + "winner": winner, + "owner": owner, + } + + +# ============================================================================== +# 5. GOOGLE SHEETS HELPERS +# ============================================================================== + +def get_col_index(headers: list, target_name: str) -> int | None: + """Ищет колонку по нормализованному canonical-названию группы.""" + target = norm(target_name) + + for index, header in enumerate(headers, start=1): + if norm(header) == target: + return index + + return None + + +def split_flags(value: str) -> list[str]: + """Разделяет содержимое row 5 по запятым, ; и переносам строк.""" + return [ + item.strip() + for item in re.split( + r"[,;\n]+", + str(value or ""), + ) + if item.strip() + ] + + +def _pad_rows( + data: list[list], + width: int, +) -> list[list]: + """Дополняет считанные строки до одинаковой ширины.""" + rows = [ + list(row) + for row in data[:5] + ] + + while len(rows) < 5: + rows.append([]) + + for row in rows: + if len(row) < width: + row.extend( + [""] * (width - len(row)) + ) + + return rows + + +def apply_event( + sheet_obj, + event: dict, +) -> None: + """ + Применяет событие к таблице. + + Инвариант: + один собственный флаг владельца не может одновременно находиться + более чем в одной ячейке row 5. + + Перед установкой новой позиции флаг удаляется из ВСЕХ колонок row 5. + + Все реальные изменения отправляются одним batch_update(). + """ + data = sheet_obj.get("1:5") + + if not data or not data[0]: + raise ValueError( + "Не удалось прочитать заголовки таблицы" + ) + + headers = data[0] + width = len(headers) + + rows = _pad_rows( + data, + width, + ) + + row_4 = rows[3] + row_5 = rows[4] + + if event["kind"] == "own_return": + owner = event["owner"] + + owner_col = get_col_index( + headers, + owner, + ) + + if not owner_col: + raise ValueError( + f"Не найдена колонка для '{owner}' в таблице" + ) + + # Убираем owner из всех row 5, + # включая старые aliases/варианты написания. + for col_index, value in enumerate(row_5): + flags = split_flags(value) + + filtered = [ + flag + for flag in flags + if canonicalize_flag_name(flag) != owner + ] + + new_value = ", ".join(filtered) + + if new_value != str(value or ""): + row_5[col_index] = new_value + + row_4[owner_col - 1] = "на месте" + + elif event["kind"] == "capture": + winner = event["winner"] + owner = event["owner"] + + winner_col = get_col_index( + headers, + winner, + ) + + owner_col = get_col_index( + headers, + owner, + ) + + if not winner_col or not owner_col: + missing = [] + + if not winner_col: + missing.append(winner) + + if not owner_col: + missing.append(owner) + + raise ValueError( + f"Не найдены колонки: {', '.join(missing)}" + ) + + # Сначала удаляем owner из ВСЕХ старых позиций. + for col_index, value in enumerate(row_5): + flags = split_flags(value) + + filtered = [ + flag + for flag in flags + if canonicalize_flag_name(flag) != owner + ] + + new_value = ", ".join(filtered) + + if new_value != str(value or ""): + row_5[col_index] = new_value + + # Затем помещаем owner в колонку winner. + winner_index = winner_col - 1 + + winner_flags = split_flags( + row_5[winner_index] + ) + + existing_canonical = { + canonicalize_flag_name(flag) or norm(flag) + for flag in winner_flags + } + + if owner not in existing_canonical: + winner_flags.append(owner) + + row_5[winner_index] = ", ".join( + winner_flags + ) + + # Статус собственного флага owner. + row_4[owner_col - 1] = f"у {winner}" + + else: + raise ValueError( + f"Неизвестный тип события: {event['kind']}" + ) + + original_rows = _pad_rows( + data, + width, + ) + + original_row_4 = original_rows[3] + original_row_5 = original_rows[4] + + updates = [] + + # Изменения строки 4. + for index in range(width): + old = str( + original_row_4[index] or "" + ) + + new = str( + row_4[index] or "" + ) + + if old != new: + updates.append( + { + "range": rowcol_to_a1( + 4, + index + 1, + ), + "values": [[new]], + } + ) + + # Изменения строки 5. + for index in range(width): + old = str( + original_row_5[index] or "" + ) + + new = str( + row_5[index] or "" + ) + + if old != new: + updates.append( + { + "range": rowcol_to_a1( + 5, + index + 1, + ), + "values": [[new]], + } + ) + + if updates: + sheet_obj.batch_update( + updates + ) + + +# ============================================================================== +# 6. КЭШ СВОДКИ +# ============================================================================== + +_summary_cache = { + "data": None, + "timestamp": 0.0, + "modified_time": None, +} + + +# Последовательная обработка операций записи. +# Это защищает от одновременного чтения/изменения/записи +# одной и той же версии таблицы. +sheet_update_lock = asyncio.Lock() + + +def invalidate_summary_cache() -> None: + _summary_cache["data"] = None + _summary_cache["timestamp"] = 0.0 + _summary_cache["modified_time"] = None + + +def get_sheet_modified_time( + creds, +) -> str: + """ + Возвращает дату и время последнего изменения файла Google Sheets + в часовом поясе Africa/Cairo. + """ + try: + session = AuthorizedSession( + creds + ) + + response = session.get( + f"https://www.googleapis.com/drive/v3/files/{SPREADSHEET_ID}", + params={ + "fields": "modifiedTime", + }, + timeout=20, + ) + + if response.status_code != 200: + logging.warning( + "Не удалось получить modifiedTime таблицы: " + "HTTP %s: %s", + response.status_code, + response.text, + ) + + return "не удалось определить" + + modified_time = response.json().get( + "modifiedTime" + ) + + if not modified_time: + return "не удалось определить" + + dt = datetime.fromisoformat( + modified_time.replace( + "Z", + "+00:00", + ) + ) + + dt = dt.astimezone( + ZoneInfo("Africa/Cairo") + ) + + return dt.strftime( + "%d.%m.%Y %H:%M:%S" + ) + + except Exception as exc: + logging.warning( + "Ошибка получения времени изменения таблицы: %s", + exc, + ) + + return "не удалось определить" + + +def build_summary_from_data( + data: list[list], +) -> str: + if not data or len(data) < 5: + return "📊 Таблица пуста или не заполнена." + + headers = data[0] + + row_4 = ( + data[3] + if len(data) > 3 + else [] + ) + + row_5 = ( + data[4] + if len(data) > 4 + else [] + ) + + known_groups = { + norm(item) + for item in GROUP_ALIASES + } + + own_lines = [] + captured_lines = [] + + # Колонка 0 = служебная "Группы и комитеты". + for col in range( + 1, + len(headers), + ): + group = str( + headers[col] or "" + ).strip() + + if not group: + continue + + if norm(group) not in known_groups: + continue + + own_status = ( + str( + row_4[col] or "" + ).strip() + if col < len(row_4) + else "" + ) + + captured = ( + str( + row_5[col] or "" + ).strip() + if col < len(row_5) + else "" + ) + + if not own_status: + own_text = ( + "статус своего флага не указан" + ) + + elif norm(own_status) == "на месте": + own_text = ( + "свой флаг на месте" + ) + + else: + own_text = ( + f"свой флаг {own_status}" + ) + + own_lines.append( + f"🚩 *{group}*: {own_text}" + ) + + for flag in split_flags( + captured + ): + captured_lines.append( + f"🏴 {group} ← {flag}" + ) + + sections = [] + + # Первый блок: где сейчас находится свой флаг. + if own_lines: + sections.append( + "🚩 СОСТОЯНИЕ СОБСТВЕННЫХ ФЛАГОВ\n" + + "\n".join(own_lines) + ) + + # Второй блок: кто какие чужие флаги захватил. + if captured_lines: + sections.append( + "\n🏴 ЗАХВАЧЕННЫЕ ФЛАГИ\n" + + "\n".join(captured_lines) + ) + + return ( + "\n".join(sections) + if sections + else "📊 Таблица пуста или не заполнена." + ) + + +def generate_summary( + sheet_obj, +) -> str: + data = sheet_obj.get( + "1:5" + ) + + return build_summary_from_data( + data + ) + + +def get_cached_summary( + sheet_obj, + creds, +) -> tuple[str, str]: + now = time.time() + + if ( + _summary_cache["data"] is not None + and now - _summary_cache["timestamp"] + < CACHE_TTL_SECONDS + ): + return ( + _summary_cache["data"], + _summary_cache["modified_time"], + ) + + summary = generate_summary( + sheet_obj + ) + + modified_time = get_sheet_modified_time( + creds + ) + + _summary_cache["data"] = summary + _summary_cache["timestamp"] = now + _summary_cache["modified_time"] = modified_time + + return ( + summary, + modified_time, + ) + + +# ============================================================================== +# 7. TELEGRAM HELPERS / FILTERS +# ============================================================================== + +def get_message_text( + message: types.Message, +) -> str: + return ( + message.text + or message.caption + or "" + ) + + +def has_bot_mention( + text: str, +) -> bool: + return norm(BOT_MENTION) in norm(text) + + +def is_summary_text( + text: str, +) -> bool: + """ + Распознаёт только команду сводки. + + Поддерживаются: + - флаги + - /флаги + - @googleanvrn_bot флаги + + Команда summary намеренно НЕ поддерживается. + """ + clean = norm(text) + + if not clean: + return False + + mention = norm( + BOT_MENTION + ) + + if mention: + clean = clean.replace( + mention, + " ", + ).strip() + + clean = clean.lstrip( + "/" + ).strip() + + return clean == "флаги" + + +def is_summary_filter( + message: types.Message, +) -> bool: + return is_summary_text( + get_message_text(message) + ) + + +def is_flag_event_filter( + message: types.Message, +) -> bool: + text = get_message_text( + message + ) + + if not text: + return False + + if is_summary_text(text): + return False + + return ( + has_bot_mention(text) + and "флаг" in norm(text) + ) + + +# ============================================================================== +# 8. ПОМОЩЬ +# ============================================================================== + +HELP_TEXT = ( + "🤖 Бот учета флагов\n\n" + "Если захватили флаг:\n" + f"{BOT_MENTION} Космос забрал флаг у ЮЗ\n\n" + "Если вернули свой флаг:\n" + f"{BOT_MENTION} Космос вернул свой флаг\n\n" + "Чтобы посмотреть статистику:\n" + "флаги\n\n" + "Как пользоваться ботом:\n" + "/help\n" + "/помощь" +) + + +# ============================================================================== +# 9. GOOGLE + TELEGRAM +# ============================================================================== + +def connect_sheet(): + if not CREDENTIALS_FILE.exists(): + raise FileNotFoundError( + "Файл credentials.json не найден: " + f"{CREDENTIALS_FILE}" + ) + + if not BOT_TOKEN.strip(): + raise ValueError( + "BOT_TOKEN не заполнен" + ) + + if not SPREADSHEET_ID.strip(): + raise ValueError( + "SPREADSHEET_ID не заполнен" + ) + + creds = Credentials.from_service_account_file( + str(CREDENTIALS_FILE), + scopes=SCOPES, + ) + + gc = gspread.authorize( + creds + ) + + worksheet = gc.open_by_key( + SPREADSHEET_ID + ).sheet1 + + logging.info( + "✅ Google Таблица успешно подключена" + ) + + return ( + creds, + gc, + worksheet, + ) + + +async def cmd_start( + message: types.Message, +): + await message.answer( + HELP_TEXT + ) + + +async def cmd_help( + message: types.Message, +): + await message.answer( + HELP_TEXT + ) + + +async def cmd_summary( + message: types.Message, +): + await message.answer( + "⏳ Собираю данные из таблицы..." + ) + + try: + summary, modified_time = ( + await asyncio.to_thread( + get_cached_summary, + sheet, + creds, + ) + ) + + response = ( + f"🕒 *Последнее обновление таблицы:* " + f"{modified_time}\n\n" + f"{summary}" + ) + + await message.answer( + response, + parse_mode="Markdown", + ) + + except gspread.exceptions.APIError as exc: + if "429" in str(exc): + logging.warning( + "⚠️ Превышен лимит запросов Google Sheets (429)" + ) + + await message.answer( + "⚠️ Google временно ограничил чтение таблицы. " + "Повторите запрос через минуту." + ) + + else: + logging.error( + "Ошибка Google API: %s", + exc, + exc_info=True, + ) + + await message.answer( + "❌ Ошибка при обращении к Google Таблице." + ) + + except Exception as exc: + logging.error( + "Ошибка генерации сводки: %s", + exc, + exc_info=True, + ) + + await message.answer( + "❌ Не удалось прочитать таблицу." + ) + + +async def handle_flag_event( + message: types.Message, +): + text = get_message_text( + message + ) + + event = parse_flag_event( + text + ) + + if not event: + await message.reply( + "⚠️ Не удалось однозначно распознать событие.\n\n" + "Примеры:\n" + f"{BOT_MENTION} Космос забрал флаг у ЮЗ\n" + f"{BOT_MENTION} Космос вернул свой флаг" + ) + + return + + try: + # Все изменения таблицы выполняются последовательно. + async with sheet_update_lock: + await asyncio.to_thread( + apply_event, + sheet, + event, + ) + + invalidate_summary_cache() + + if event["kind"] == "own_return": + response = ( + f"✅ Зафиксировано: *{event['owner']}* " + "вернул(а) свой флаг. " + "Теперь он на месте." + ) + + else: + response = ( + f"✅ Зафиксировано: *{event['winner']}* " + f"забрал(а) флаг у *{event['owner']}*." + ) + + await message.reply( + response, + parse_mode="Markdown", + ) + + except ValueError as exc: + logging.warning( + "Ошибка валидации: %s", + exc, + ) + + await message.reply( + f"❌ {exc}" + ) + + except gspread.exceptions.APIError as exc: + if "429" in str(exc): + logging.warning( + "⚠️ Превышен лимит запросов Google Sheets (429)" + ) + + await message.reply( + "⚠️ Google временно ограничил запись в таблицу. " + "Повторите через минуту." + ) + + else: + logging.error( + "Ошибка Google API: %s", + exc, + exc_info=True, + ) + + await message.reply( + "❌ Ошибка записи в Google Таблицу." + ) + + except Exception as exc: + logging.error( + "Критическая ошибка записи: %s", + exc, + exc_info=True, + ) + + await message.reply( + "❌ Произошла непредвиденная ошибка при записи." + ) + + +# ============================================================================== +# 10. ЗАПУСК +# ============================================================================== + +async def main(): + global bot, dp, gc, sheet, creds + + creds, gc, sheet = connect_sheet() + + bot = Bot( + token=BOT_TOKEN + ) + + dp = Dispatcher() + + # /start + dp.message.register( + cmd_start, + Command("start"), + ) + + # /help + dp.message.register( + cmd_help, + Command("help"), + ) + + # /помощь + dp.message.register( + cmd_help, + Command("помощь"), + ) + + # /флаги + dp.message.register( + cmd_summary, + Command("флаги"), + ) + + # флаги + # @googleanvrn_bot флаги + dp.message.register( + cmd_summary, + is_summary_filter, + ) + + # События захвата/возврата флагов. + dp.message.register( + handle_flag_event, + F.text | F.caption, + is_flag_event_filter, + ) + + logging.info( + "🚀 Бот запущен" + ) + + await dp.start_polling( + bot + ) + + +bot = None +gc = None +sheet = None +creds = None +dp = Dispatcher() + + +# ============================================================================== +# 11. ENTRY POINT +# ============================================================================== + +if __name__ == "__main__": + try: + asyncio.run( + main() + ) + + except KeyboardInterrupt: + logging.info( + "🛑 Бот остановлен пользователем" + ) \ No newline at end of file