Files
2026-08-18 12:03:33 +00:00

321 lines
17 KiB
Python
Raw Permalink 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("🛑 Бот остановлен пользователем.")