1298 lines
37 KiB
Python
1298 lines
37 KiB
Python
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"(?<![а-яa-z0-9_-])"
|
||
rf"{re.escape(norm(alias))}"
|
||
rf"(?![а-яa-z0-9_-])"
|
||
)
|
||
|
||
|
||
COMPILED_ALIASES = [
|
||
(alias, canonical, _alias_pattern(alias))
|
||
for alias, canonical in ALIASES
|
||
]
|
||
|
||
|
||
def find_groups(text: str) -> 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(
|
||
"🛑 Бот остановлен пользователем"
|
||
) |