Files
GoogleSheetAnBot/bot.py
T

465 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
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("🛑 Бот остановлен пользователем.")