From 4d526d009edea7fff7adce03cc8503f3bbec9847 Mon Sep 17 00:00:00 2001 From: mtorov Date: Tue, 18 Aug 2026 12:03:33 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.0 Signed-off-by: mtorov --- README | 5 + bot.py | 321 +++++++++++++++++++++++++++++++++++++++++++++++ credentials.json | 13 ++ 3 files changed, 339 insertions(+) create mode 100644 README create mode 100644 bot.py create mode 100644 credentials.json diff --git a/README b/README new file mode 100644 index 0000000..fe70e64 --- /dev/null +++ b/README @@ -0,0 +1,5 @@ +почта бота : telegram-bot-sa@tableanbot.iam.gserviceaccount.com +ключ в файле + +имя бота : @googleAnVrn_bot +его токен : 8962252858:AAEJ8nNGOSwvENfWZAw3lI1RZr_FLL4G3FU \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..53add09 --- /dev/null +++ b/bot.py @@ -0,0 +1,321 @@ +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 diff --git a/credentials.json b/credentials.json new file mode 100644 index 0000000..90249b6 --- /dev/null +++ b/credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "tableanbot", + "private_key_id": "b8075582da585fe8372f1ee9b638ba72227bcced", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCua1OtbLTousnH\n54//QBUNlEDrD+APLTP9TQHBWsDFM5LMleYzaY1S89pZKzLl2XuHwb4RgkNTxK8q\nUceddTdwAZsXEQjGm8EmEr5MdvGFcTnlKyU2U+bXzRFzqgs54Mk+R53B2gobKvlI\nWms3rG5y4dIW7jkyqa63gTypApgWxlnnIcQkBjQFlT1Gd2oXR6JIqhA+9bm2ucR0\nE5Z5KieqnGyScPtg+TtUFknNqlyCrmlQ8Ui7r2o+rrYxyS90mDjYE5ulKpvky8Gr\n6rWqHPSLirQ6C5zv2wtXjJHAFrdpxw+eDcKml9qaL9/E6pBTLf7NYA6aX3s6G/0H\njWi0uFzNAgMBAAECggEALpS2Q74g8HiWE/+5OruEA9zgm4ZhWdxCIrdSrS9OBTx5\nS48K7BcX6Zv7Z838KVt0PLTWzHlD/taryzsM+vXHiO+82iOv+s60/jSKQwSPCwrK\nofPsW757i42WSBQVLG9mf24D6TYQdeH7skGHmdZApwi9IWecufWMFjD7IM3zDT2Z\nxSOByNJ/M+aA0K6cE6G/yJfzCUP/0J1EevYLMJS0lh8O5C2J5jlwGm2CDfpU+6Tq\nhLGgFgH2KAqry1t6sh5YsCM8aer5Umg3RT5Ott9h6MnTM8DdQKicV0smGTXe7+UR\nd9+EWFDm4wjM+NXhBitv0WiF0wrIJPCys6M2QBQqFwKBgQDg/QM03Dtc05YAcucQ\nEAfAktAowJfTSGzzoP1YQN03h70yPD5uPZB0n6AbAg4+Es/nFZsOF9zUYQwEhNWM\nFWPUw11o5bCfl0QBzkFLD6IPMlpoq+h/CKXw0dN2JN6Tc1YUbHU8UwiB7v+ESePT\n7ClZe8SJSpO2iCarcPgk4imaMwKBgQDGdewsLmOsHy8x1tdfxAOfLyFtBd+R7LkM\n4KcQoJp/9CGFsFsq/aOGpnSF39NGo2KXPbnlqlpF295FJYzkx7PziaTscYp7EKPB\nr/1MxwQrKlgK8Qw7zKIUBbR3K4+pxsYHZoAADshZoK8w16aTgY0YrhuSe13geZNT\nlk5x9R0s/wKBgDksUnpGVNd79SxcBpewWyf2pusUNgC88OLgUEDzCsQnvE5dvMeq\n4WChh8AVP6VKhoaEmQh1y+o0Vyd20gVtEhdpJf2YENAdUmzbCwCWEmqmMw9SUByo\nB/MwfvoQ2HbD4mYCQbB4sTrDjy8Az+J0qvVQXFs/FlZWNu1uIcDNsCC9AoGBAJ+j\niad8ZUa2CvOPOs3Y0YUBl2SoPc+W2L/IEfrpBX55dWGhm6Q4RIryaHartbpzgMHh\nsCBS6KybEO8UJoJ9NX5oMRbUui0GFIOfuiRBTlQeipW6kjmkAJgUgrb9fmF1+CNJ\nyxrAyV53R5UFUwQCSOoaWyl4POptQRoSpOU74w0nAoGBANvh5iSnzgN1IOzjsRmd\nLUKEjJ8W9XUAhDihXjxgcpR8nOYkWWuK9dJqpYPVYauJHm12GVZgAD7rHGc0o6Tw\n/j+kXmvPXDxdQuSuo6YUBPH/v0LVCh101Wy25baH4iLdEqBCLDAzNSO+k3dgy+C0\niOzB5EKr4m/Zi/Cke2l3CY/h\n-----END PRIVATE KEY-----\n", + "client_email": "telegram-bot-sa@tableanbot.iam.gserviceaccount.com", + "client_id": "111422416865264396667", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/telegram-bot-sa%40tableanbot.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}